From d1d97d87c7806d8633e2149762127001ddfe411f Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 13 Aug 2026 12:18:56 -0700 Subject: [PATCH 01/45] feat(config): consolidate all give-up deadlines into settings.timeouts; make --timeout a real run watchdog Reworked from PR #409 review feedback, rebuilt on latest main: - New frozen Timeouts model at settings.timeouts holds every give-up deadline: run_timeout_s (--timeout, whole-run watchdog), service-ready, per-phase drains (absorbs DrainConfig), metrics drain (0-sentinel killed; None = unlimited), and the worker lifecycle waits (moved off settings.client; carriers renamed *_s, excluded from dumps and CLI). - --timeout was consumed nowhere; it now aborts the run: session.stop() then SIGTERM the aggregator (INTERRUPTED final snapshot, first-wins), ExecutionError after finalization - a fired watchdog can never yield a COMPLETE result_summary.json. Deadline is captured before setup; the timer stays armed through the metrics drain. Timed-out runs skip accuracy scoring; audit phases map a fired watchdog to ExecutionError. - publish_final serialized with an asyncio.Lock: a SIGTERM racing the ENDED-driven finalize can no longer abandon a half-written snapshot. - runtime.min_duration_ms/--duration deleted: sample count is explicit (--num-samples) or the dataset issued once. max_duration_ms stays in runtime as the perf-phase workload cap (int|None, gt 0); reaching it is a normal end. MLPerf ruleset path (RuntimeSettings/UserConfig) keeps its internal duration fields. - ServiceLauncher.terminate(module): exact-match SIGTERM; MetricsPipeline.terminate_metrics_aggregator() is the narrow public face. - config/schema.py split into enums/audit/model_params/datasets/settings/ timeouts modules; schema.py keeps the root aggregate + re-export hub. SystemDefaults and TEMPLATE_TYPE_MAP deleted. - Examples, templates, and docs migrated; docs gain a YAML<->CLI time-knob table. Stale inert timeout: values dropped, warmup drain removed from examples. Breaking: bare configs (no --num-samples) now run the dataset once instead of deriving QPS x 10min samples; old YAML keys hard-error via extra=forbid. --- .pre-commit-config.yaml | 2 +- AGENTS.md | 18 +- docs/CLI_DESIGN.md | 3 +- docs/CLI_QUICK_REFERENCE.md | 44 +- docs/LOCAL_TESTING.md | 13 +- docs/config/DESIGN.md | 22 +- examples/02_ServerBenchmarking/README.md | 2 +- .../offline_llama3_8b_cnn.yaml | 4 +- .../online_llama2_70b_cnn.yaml | 4 +- examples/03_BenchmarkComparison/README.md | 22 +- .../compare_with_vllm.py | 10 +- examples/04_GPTOSS120B_Example/Readme.md | 8 +- .../gptoss_120b_example.yaml | 4 +- examples/04_GPTOSS120B_Example/run.py | 7 - .../sglang_gptoss_120b_example.yaml | 5 +- .../vllm_gptoss_120b_example.yaml | 5 +- ...m_gptoss_120b_per_dataset_osl_example.yaml | 8 +- examples/05_Llama_Examples/README.md | 6 +- .../offline_llama3_8b_cnn.yaml | 3 +- .../online_llama2_70b_orca.yaml | 4 +- .../online_llama3_8b_cnn.yaml | 3 +- ...ractive_qwen3_vl_235b_a22b_shopify_8k.yaml | 8 +- .../offline_qwen3_vl_235b_a22b_shopify.yaml | 14 +- .../server_qwen3_vl_235b_a22b_shopify.yaml | 9 +- .../offline_wan22_submission.yaml | 6 +- .../single_stream_wan22_submission.yaml | 6 +- .../kimi_agentic_benchmark.yaml | 3 - .../qwen_agentic_benchmark.yaml | 3 +- .../online_edge_full_run.yaml | 5 +- scripts/bench_drain_tokenize.py | 301 +++++ scripts/regenerate_templates.py | 2 - .../async_utils/services/launcher.py | 14 + .../services/metrics_aggregator/publisher.py | 38 +- src/inference_endpoint/commands/audit.py | 7 + .../commands/benchmark/cli.py | 10 +- .../commands/benchmark/execute.py | 164 ++- .../commands/benchmark/pipeline.py | 27 +- src/inference_endpoint/config/audit.py | 84 ++ src/inference_endpoint/config/datasets.py | 284 +++++ src/inference_endpoint/config/enums.py | 131 +++ src/inference_endpoint/config/model_params.py | 165 +++ .../config/rulesets/mlcommons/rules.py | 3 +- .../config/runtime_settings.py | 23 +- src/inference_endpoint/config/schema.py | 1037 ++--------------- src/inference_endpoint/config/settings.py | 343 ++++++ .../templates/concurrency_template.yaml | 2 - .../templates/concurrency_template_full.yaml | 25 +- .../config/templates/offline_template.yaml | 2 - .../templates/offline_template_full.yaml | 25 +- .../config/templates/online_template.yaml | 2 - .../templates/online_template_full.yaml | 25 +- .../config/templates/submission_template.yaml | 1 - src/inference_endpoint/config/timeouts.py | 145 +++ .../endpoint_client/config.py | 25 +- .../endpoint_client/worker_manager.py | 8 +- .../commands/test_accuracy_pipeline.py | 2 - .../commands/test_benchmark_command.py | 14 +- tests/integration/commands/test_cli.py | 10 +- .../integration/commands/test_run_timeout.py | 172 +++ tests/integration/commands/test_warmup.py | 2 +- .../async_utils/services/test_launcher.py | 46 + .../async_utils/transport/test_protocol.py | 113 ++ tests/unit/commands/test_benchmark.py | 164 +-- tests/unit/compliance/test_output_caching.py | 23 + tests/unit/config/test_schema.py | 27 +- tests/unit/config/test_timeouts.py | 294 +++++ tests/unit/config/test_yaml_loader.py | 15 +- .../scripts/test_metrics_preflight_tap.py | 410 +++++++ 68 files changed, 3095 insertions(+), 1341 deletions(-) create mode 100644 scripts/bench_drain_tokenize.py create mode 100644 src/inference_endpoint/config/audit.py create mode 100644 src/inference_endpoint/config/datasets.py create mode 100644 src/inference_endpoint/config/enums.py create mode 100644 src/inference_endpoint/config/model_params.py create mode 100644 src/inference_endpoint/config/settings.py create mode 100644 src/inference_endpoint/config/timeouts.py create mode 100644 tests/integration/commands/test_run_timeout.py create mode 100644 tests/unit/async_utils/services/test_launcher.py create mode 100644 tests/unit/async_utils/transport/test_protocol.py create mode 100644 tests/unit/config/test_timeouts.py create mode 100644 tests/unit/scripts/test_metrics_preflight_tap.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d2a6fdfb7..a111ddbf0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: entry: uv run --no-sync python scripts/regenerate_templates.py language: system pass_filenames: false - files: ^(src/inference_endpoint/config/(schema\.py|templates/.*)|src/inference_endpoint/endpoint_client/config\.py|scripts/regenerate_templates\.py)$ + files: ^(src/inference_endpoint/config/((schema|enums|audit|model_params|datasets|settings|timeouts)\.py|templates/.*)|src/inference_endpoint/endpoint_client/config\.py|scripts/regenerate_templates\.py)$ - id: add-license-header name: Add license headers diff --git a/AGENTS.md b/AGENTS.md index 9ef6f83ef..4c41fde22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Dataset Manager --> Load Generator --> Endpoint Client --> External Endpoint | **Dataset Manager** | `src/inference_endpoint/dataset_manager/` | Loads JSONL, HuggingFace, CSV, JSON, Parquet datasets. `Dataset` base class with `load_sample()`/`num_samples()` interface | | **Metrics Aggregator** | `src/inference_endpoint/async_utils/services/metrics_aggregator/` | Subprocess. Subscribes to events, aggregates per-sample metrics into a `MetricsRegistry` (counters + HDR-histogram series + raw values), publishes `MetricsSnapshot` over IPC PUB at a configurable cadence (`SessionState`: `INITIALIZE` → `LIVE` → `DRAINING` → {`COMPLETE` \| `INTERRUPTED`}). Final snapshot is atomically written to `final_snapshot.json` as the **primary** Report source; the terminal pub/sub frame is a TUI "run finished" signal only. | | **Report** | `src/inference_endpoint/metrics/report.py` | `Report.from_snapshot(dict)` — pure-function builder consuming the dict form (`snapshot_to_dict`). Reads `final_snapshot.json` directly via `json.loads` (no Struct decode). Plumbs `complete = (state == "complete" and n_pending_tasks == 0)`; renders an explicit warning for `INTERRUPTED` runs. | -| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema (`schema.py`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | +| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema split into focused modules (`schema.py` = BenchmarkConfig + EndpointConfig + re-export hub; `enums.py`, `audit.py`, `model_params.py`, `datasets.py`, `settings.py`), `Timeouts` (`config/timeouts.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | | **CLI** | `src/inference_endpoint/main.py`, `commands/benchmark/cli.py` | cyclopts-based, auto-generated from `schema.py` and `HTTPClientConfig` Pydantic models. Flat shorthands via `cyclopts.Parameter(alias=...)` | | **Async Utils** | `src/inference_endpoint/async_utils/` | `LoopManager` (uvloop + eager_task_factory), ZMQ transport layer, generic `MessageCodec[T]`-parametrized pub/sub, event publisher | | **OpenAI/SGLang** | `src/inference_endpoint/openai/`, `sglang/` | Protocol adapters and response accumulators for different API formats. `openai_completions` adapter (`completions_adapter.py`) sends pre-tokenized token IDs to `/v1/completions`, bypassing the server chat template — required for gpt-oss-120b on vLLM. `sglang` adapter sends to `/generate` via `input_ids`. Both apply `Harmonize()` client-side. | @@ -118,7 +118,7 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. - **Series storage**: each `SeriesSampler` keeps three parallel views: O(1) cheap rollups (count/total/min/max/sum_sq, exact), an HDR Histogram (cheap live percentiles), and an in-memory `array.array` of raw values (for exact percentiles in the `COMPLETE` snapshot). Hot path is `registry.record(name, value)` — no allocation, no I/O. - **Counter API**: `registry.increment(name, delta=1)` for sample-event counters. `registry.set_counter(name, value)` only for the three derived-duration counters (`total_duration_ns` max-of-elapsed, `tracked_duration_ns` sum-of-blocks, `legacy_loadgen_window_duration_ns` first-issue→last-issued-completion span for LoadGen-parity QPS/TPS). -- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget — schema default 0 = unlimited) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0`; interrupted runs are detected as `state == INTERRUPTED` directly. +- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget — argv 0 = unlimited; schema `settings.timeouts.metrics_drain_timeout_s` uses None = unlimited, converted at the argv boundary) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0`; interrupted runs are detected as `state == INTERRUPTED` directly. - **Final delivery is dual-path with separated concerns**: `publish_final` atomically writes `final_snapshot.json` (`tmp + fsync(file) + rename + fsync(parent_dir)`) — this is the **primary** Report source — AND emits the terminal-state snapshot over pub/sub as a TUI shutdown signal. Each path is wrapped in its own try/except so one failure cannot suppress the other. Main process consumer reads `final_snapshot.json` (via `json.loads` to dict, no Struct decode); falls back to the subscriber's `latest` live snapshot only if the file is missing (e.g. SIGKILL / OOM before the signal handler ran). The dict form is the canonical consumer contract (see `snapshot_to_dict`). - **Early stopping (on by default)**: series registered with `register_series(..., tail_latency=True)` (today ttft/tpot/latency) get MLPerf early-stopping percentile estimates on the COMPLETE (exact) snapshot — a compact `early_stopping_percentiles` map in `result_summary.json` whose keys mirror the `percentiles` grid (≥ p50) with estimate-or-`null` values; rich detail is INFO-logged. On by default (cold-path only; the exact path shares one in-place sort between the percentile grid and the estimates); `settings.early_stopping.enabled: false` / `--no-early-stopping` opts out. Confidence/tolerance are LoadGen constants. Pure math in `metrics/early_stopping.py`; post-hoc recomputation from any run's `events.jsonl` via `scripts/early_stopping_estimate_from_events.py`. See docs/early_stopping.md. - **Histogram bucket edges are dynamic per snapshot**: log-spaced over the observed `[min, max]`. Bucket count is fixed at construction; consumers MUST re-render from the snapshot's `(lo, hi, count)` triples each frame and MUST NOT track bucket-by-index across snapshots. @@ -128,7 +128,7 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. CLI is auto-generated from `config/schema.py` Pydantic models via cyclopts. Fields annotated with `cyclopts.Parameter(alias="--flag")` get flat shorthands; all other fields get auto-generated dotted flags (kebab-case). - **CLI mode** (`offline`/`online`): cyclopts constructs `OfflineBenchmarkConfig`/`OnlineBenchmarkConfig` (subclasses in `config/schema.py`) directly from CLI args. Type locked via `Literal`. `--dataset` is repeatable with TOML-style format `[perf|acc:][,key=value...]` (e.g. `--dataset data.csv,samples=500,parser.prompt=article`). Full accuracy support via `accuracy_config.eval_method=pass_at_1` etc. -- **YAML mode** (`from-config`): `BenchmarkConfig.from_yaml_file()` loads YAML, resolves env vars, and auto-selects the right subclass via Pydantic discriminated union. Optional `--timeout`/`--mode` overrides via `config.with_updates()`. +- **YAML mode** (`from-config`): `BenchmarkConfig.from_yaml_file()` loads YAML, resolves env vars, and auto-selects the right subclass via Pydantic discriminated union. Optional `--timeout` (maps to `settings.timeouts.run_timeout_s`)/`--mode` overrides via `config.with_updates()`. - **eval**: Not yet implemented (raises `CLIError` with a tracking issue link) ### Config Construction & Validation @@ -148,9 +148,9 @@ YAML from-config: from_yaml_file(path) → discriminated union → same subcl Validation is layered: -1. **Field-level** (Pydantic): `Field(ge=0)` on durations, `Field(ge=-1)` on workers, `Literal` on `benchmark_mode` +1. **Field-level** (Pydantic): `Field(gt=0)` on durations/deadlines, `Field(ge=-1)` on workers, `Literal` on `benchmark_mode` 2. **Field validators**: `workers != 0` check -3. **Model validator** (`_resolve_and_validate`): streaming AUTO resolution, model name from `submission_ref`, load pattern vs test type, cross-field duration check, duplicate datasets +3. **Model validator** (`_resolve_and_validate`): streaming AUTO resolution, model name from `submission_ref`, load pattern vs test type, duplicate datasets ### Load Patterns @@ -244,7 +244,13 @@ src/inference_endpoint/ │ ├── early_stopping.py # MLPerf LoadGen early-stopping percentile estimates (pure math; see docs/early_stopping.md) │ └── results_plots.py # Standardized run-artifact plots (matplotlib-guarded); CLI: scripts/plot_results.py ├── config/ -│ ├── schema.py # Single source of truth: Pydantic models + cyclopts annotations +│ ├── schema.py # BenchmarkConfig + EndpointConfig; re-export hub for the schema surface +│ ├── enums.py # Shared schema enums (TestType, LoadPatternType, StreamingMode, ...) +│ ├── audit.py # Audit config models (audit: YAML block) +│ ├── model_params.py # ModelParams, OSLDistribution, SubmissionReference +│ ├── datasets.py # Dataset, AccuracyConfig, AgenticInferenceConfig +│ ├── settings.py # Settings + Runtime/LoadPattern/Warmup/Profiling/EarlyStopping configs +│ ├── timeouts.py # Timeouts — all give-up deadlines (settings.timeouts) │ ├── runtime_settings.py # RuntimeSettings + SampleOrderSpec dataclasses │ ├── ruleset_base.py # BenchmarkSuiteRuleset base │ ├── ruleset_registry.py # Ruleset registry diff --git a/docs/CLI_DESIGN.md b/docs/CLI_DESIGN.md index da4799a14..474c81fb9 100644 --- a/docs/CLI_DESIGN.md +++ b/docs/CLI_DESIGN.md @@ -131,7 +131,6 @@ Validation is layered, executing in order: 1. cyclopts → required args? unknown flags? 2. Pydantic fields → type coercion, ge/le constraints 3. Sub-model validators: - ├── RuntimeConfig._validate_durations → max >= min duration ├── LoadPattern._validate_completeness → poisson needs qps, concurrency needs target └── HTTPClientConfig._workers_not_zero → num_workers != 0 4. BenchmarkConfig._resolve_and_validate: @@ -204,5 +203,5 @@ class HTTPClientConfig(WithUpdatesMixin, BaseModel): `BenchmarkConfig` is frozen. Use `with_updates()` to produce new instances with re-validation: ```python -config = config.with_updates(timeout=300, datasets=["new_data.jsonl"]) +config = config.with_updates(report_dir="results/run1", datasets=["new_data.jsonl"]) ``` diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 2367e1c93..6f69d7b6b 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -96,8 +96,7 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. - `--model-params.max-new-tokens --max-output-tokens` - Max output tokens (default: 1024) - `--model-params.osl-distribution.min --min-output-tokens` - Min output tokens (default: 1) - `--model-params.streaming --streaming` - Streaming mode: auto/on/off (default: auto) -- `--runtime.min-duration-ms --duration` - Min duration: ms default, or with suffix (600s, 10m) (default: 600000) -- `--runtime.n-samples-to-issue --num-samples` - Explicit sample count override +- `--runtime.n-samples-to-issue --num-samples` - Explicit sample count (omit to issue the dataset once — the default) - `--client.num-workers --workers` - HTTP workers (-1=auto, default: -1) - `--client.max-connections --max-connections` - Max TCP connections (-1=unlimited) - `--endpoint-config.api-key --api-key` - API authentication @@ -106,7 +105,7 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. Note: applies to CLI-driven `benchmark offline` / `benchmark online`; `benchmark from-config` does not expose a CLI override for `report_dir`. Set it in the YAML only if you need to control the output location; otherwise a default report directory is used. -- `--timeout` - Global timeout in seconds +- `--timeout` - Whole-run watchdog in seconds (off by default). If it fires, the run is aborted, the report is marked INTERRUPTED, and the process exits non-zero. - `--enable-cpu-affinity / --no-cpu-affinity` - NUMA-aware CPU pinning (default: true) - `--no-early-stopping` - opt out of the MLPerf early-stopping percentile estimates in `result_summary.json` (default: on; see [early_stopping.md](early_stopping.md)) @@ -118,6 +117,35 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. **All other schema fields** are accessible via dotted paths (e.g., `--model-params.temperature`, `--model-params.top-k`, `--runtime.scheduler-random-seed`). Run `--help` to see the full list. +## Time Knobs + +All give-up deadlines live under `settings.timeouts`; the only workload duration is +`settings.runtime.max_duration_ms`. `null`/unset means "wait indefinitely" (or "off") everywhere. + +| YAML path | CLI flag | Semantics | +| --------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps the performance phase (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid | +| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog; firing aborts the entire run — report marked INTERRUPTED, non-zero exit | +| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | +| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | +| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely) | +| `settings.timeouts.worker_initialization_timeout_s` | `--worker-initialization-timeout-s` | Wait for endpoint-client worker processes to start (default 60) | +| `settings.timeouts.worker_graceful_shutdown_wait_s` | `--worker-graceful-shutdown-wait-s` | Post-run wait for workers to exit gracefully (default 0.5) | +| `settings.timeouts.worker_force_kill_timeout_s` | `--worker-force-kill-timeout-s` | Wait after SIGTERM before SIGKILL during worker teardown (default 0.5) | + +How the knobs compose: + +1. **`--num-samples` / dataset-once defines the work.** An explicit `runtime.n_samples_to_issue` + sets the sample count; omitting it issues the performance dataset once. +2. **`runtime.max_duration_ms` caps the performance phase** and ends it normally — remaining + samples are not issued, the report is valid. +3. **`timeouts.run_timeout_s` aborts the whole run** (every phase, drains included) — the report + is marked INTERRUPTED and the process exits non-zero. +4. **Per-phase drain timeouts bound the post-phase wait** for requests still in flight after a + phase stops issuing. + ## Environment Variables **In YAML files** — use `${VAR}` or `${VAR:-default}` syntax: @@ -224,14 +252,13 @@ inference-endpoint benchmark online \ --report-dir production_report \ -v -# Or with duration (calculates samples from target_qps * duration) +# Without --num-samples, the dataset is issued once (the default) inference-endpoint benchmark online \ --endpoints https://api.production.com \ --model Qwen/Qwen3-8B \ --dataset prod_queries.jsonl \ --load-pattern poisson \ --target-qps 100 \ - --duration 5m \ --workers 16 \ --report-dir production_report \ -v @@ -290,8 +317,7 @@ datasets: settings: runtime: - min_duration_ms: 600000 # 10 minutes - n_samples_to_issue: null # Optional: explicit sample count (null = auto-calculate) + n_samples_to_issue: null # Optional: explicit sample count (null = issue the dataset once) scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling load_pattern: @@ -331,8 +357,8 @@ Note: For submission configs, `model_params.name` is optional when `submission_r **Sample Count Control:** -- Priority: `--num-samples` > calculated (target_qps × duration) > dataset size -- Default duration: 600000ms (10 minutes) +- `--num-samples` sets an explicit sample count; without it the dataset is issued once +- Behavior change: bare configs (no `--num-samples`) now run the dataset once instead of deriving 10 minutes' worth of samples from the target QPS **Mode Requirements:** diff --git a/docs/LOCAL_TESTING.md b/docs/LOCAL_TESTING.md index b8883264e..b8bd59d03 100644 --- a/docs/LOCAL_TESTING.md +++ b/docs/LOCAL_TESTING.md @@ -74,8 +74,7 @@ Waiting for 5 responses... uv run inference-endpoint -v benchmark offline \ --endpoints http://localhost:8765 \ --model Qwen/Qwen3-8B \ - --dataset tests/assets/datasets/dummy_1k.jsonl \ - --duration 0 + --dataset tests/assets/datasets/dummy_1k.jsonl # Production test with custom params and report generation uv run inference-endpoint -v benchmark offline \ @@ -97,7 +96,7 @@ Loading: dummy_1k.jsonl Loaded 1000 samples Mode: TestMode.PERF, QPS: 10.0, Responses: False Streaming: disabled (auto, offline mode) -Min Duration: 0.0s, Expected samples: 1000 +Expected samples: 1000 Scheduler: MaxThroughputScheduler (pattern: max_throughput) Connecting: http://localhost:8765 Running... @@ -115,7 +114,6 @@ uv run inference-endpoint -v benchmark online \ --endpoints http://localhost:8765 \ --model Qwen/Qwen3-8B \ --dataset tests/assets/datasets/dummy_1k.jsonl \ - --duration 0 \ --load-pattern poisson \ --target-qps 100 \ --report-dir online_benchmark_report @@ -128,7 +126,7 @@ Loading: dummy_1k.jsonl Loaded 1000 samples Mode: TestMode.PERF, QPS: 100.0, Responses: False Streaming: enabled (auto, online mode) -Min Duration: 0.0s, Expected samples: 1000 +Expected samples: 1000 Scheduler: PoissonDistributionScheduler (pattern: poisson) Connecting: http://localhost:8765 Running... @@ -311,9 +309,8 @@ uv run inference-endpoint benchmark online \ **Sample Count Control:** -- Use `--duration 0` when you want a local test to stop after exhausting the dataset instead of running for the default timed duration -- Sample priority: `--num-samples` > dataset size (when `--duration 0`) > calculated (target_qps × duration) -- Default duration: 600000ms (10 minutes) +- By default (no `--num-samples`) a run stops after issuing the dataset once +- Use `--num-samples` for an explicit sample count **Testing & Debugging:** diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index 795efbb66..b8208c909 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -57,17 +57,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | -------------- | --------------------------------------- | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int` | calculated: QPS × duration, or explicit | -| `min_duration_ms` | `int` | runtime config | -| `max_duration_ms` | `int` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric` | primary target driving scheduler logic | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | -------------- | ----------------------------------------- | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int` | explicit, or dataset size (issue once) | +| `min_duration_ms` | `int \| None` | ruleset override path only (`UserConfig`) | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric` | primary target driving scheduler logic | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/examples/02_ServerBenchmarking/README.md b/examples/02_ServerBenchmarking/README.md index bfb8e5b95..797b1c88e 100644 --- a/examples/02_ServerBenchmarking/README.md +++ b/examples/02_ServerBenchmarking/README.md @@ -81,6 +81,6 @@ dataset["train"].to_json("cnn_dailymail_train.json") And then launch the example template. ``` -uv run inference-endpoint benchmark from-config -c examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml --timeout 600 +uv run inference-endpoint benchmark from-config -c examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml ``` diff --git a/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml b/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml index bc5b92f1d..5420d4521 100644 --- a/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml +++ b/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml @@ -19,8 +19,8 @@ datasets: settings: runtime: - min_duration_ms: 6000 # 6 seconds - max_duration_ms: 60000 # 1 minute + max_duration_ms: 60000 # 1 minute cap on the performance phase + n_samples_to_issue: 1000 # ≈ ceil(10 QPS × 6 s × 1.1) rounded up to the 1000-sample dataset (replaces the duration-derived count) scheduler_random_seed: 137 # For Poisson/distribution sampling dataloader_random_seed: 111 # For dataset shuffling diff --git a/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml b/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml index d16035447..3dac9b4bb 100644 --- a/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml +++ b/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml @@ -19,8 +19,8 @@ datasets: settings: runtime: - min_duration_ms: 60000 # 1 minute - max_duration_ms: 180000 # 3 minutes + max_duration_ms: 180000 # 3 minute cap on the performance phase + n_samples_to_issue: 1000 # ≈ ceil(10 QPS × 60 s × 1.1) = 660 rounded up to the 1000-sample dataset (replaces the duration-derived count) scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling diff --git a/examples/03_BenchmarkComparison/README.md b/examples/03_BenchmarkComparison/README.md index fbb60b9f4..d79b32176 100644 --- a/examples/03_BenchmarkComparison/README.md +++ b/examples/03_BenchmarkComparison/README.md @@ -33,17 +33,17 @@ uv run python compare_with_vllm.py --model "Qwen/Qwen2.5-0.5B-Instruct" --endpoi ### Options -| Option | Description | Default | -| --------------------- | -------------------------------- | ----------------------- | -| `--model`, `-m` | Model name (required) | - | -| `--num-prompts`, `-n` | Number of prompts | 100 | -| `--endpoint` | Server URL | `http://localhost:8000` | -| `--max-output-tokens` | Max output tokens | 2000 | -| `--timeout` | Timeout in seconds | 900 | -| `--workers` | Number of workers | 1 | -| `--verbose`, `-v` | Show full output from each run | - | -| `--dry` | Print commands without executing | - | -| `--vllm-venv-dir` | Path to vLLM virtualenv | `./vllm_venv` | +| Option | Description | Default | +| --------------------- | -------------------------------------------------------------------------------- | ----------------------- | +| `--model`, `-m` | Model name (required) | - | +| `--num-prompts`, `-n` | Number of prompts | 100 | +| `--endpoint` | Server URL | `http://localhost:8000` | +| `--max-output-tokens` | Max output tokens | 2000 | +| `--timeout` | Whole-run watchdog (seconds) passed to inference-endpoint; firing aborts the run | 900 | +| `--workers` | Number of workers | 1 | +| `--verbose`, `-v` | Show full output from each run | - | +| `--dry` | Print commands without executing | - | +| `--vllm-venv-dir` | Path to vLLM virtualenv | `./vllm_venv` | ### Example diff --git a/examples/03_BenchmarkComparison/compare_with_vllm.py b/examples/03_BenchmarkComparison/compare_with_vllm.py index fc1e6c069..64a28c7f5 100644 --- a/examples/03_BenchmarkComparison/compare_with_vllm.py +++ b/examples/03_BenchmarkComparison/compare_with_vllm.py @@ -129,7 +129,6 @@ def generate_ie_config( num_requests: int, max_output_tokens: int, workers: int, - timeout: int, report_dir: Path, config_path: Path, ) -> None: @@ -148,7 +147,6 @@ def generate_ie_config( num_requests: Number of requests to send max_output_tokens: Maximum output tokens per request workers: Number of parallel http-client workers - timeout: Timeout in seconds report_dir: Directory to save reports config_path: Path to write the config file """ @@ -177,8 +175,6 @@ def generate_ie_config( ], "settings": { "runtime": { - "min_duration_ms": 0, - "max_duration_ms": timeout * 1000, "n_samples_to_issue": num_requests, }, "load_pattern": {"type": "max_throughput"}, @@ -186,7 +182,6 @@ def generate_ie_config( }, "endpoint_config": {"endpoints": [endpoint_url]}, "report_dir": str(report_dir), - "timeout": timeout, } with open(config_path, "w") as f: @@ -227,7 +222,7 @@ def parse_args() -> argparse.Namespace: "--timeout", type=int, default=900, - help="Timeout in seconds for inference-endpoint (default: 900)", + help="Whole-run watchdog in seconds passed to inference-endpoint (default: 900)", ) parser.add_argument( "--workers", @@ -278,7 +273,7 @@ def run_inference_endpoint( endpoint_url: Server endpoint URL num_requests: Number of requests to send max_output_tokens: Maximum output tokens per request - timeout: Timeout in seconds + timeout: Whole-run watchdog in seconds passed via --timeout workers: Number of parallel http-client workers temp_dir: Temporary directory to save report and config dry_run: If True, print command without executing it @@ -301,7 +296,6 @@ def run_inference_endpoint( num_requests=num_requests, max_output_tokens=max_output_tokens, workers=workers, - timeout=timeout, report_dir=report_dir, config_path=config_path, ) diff --git a/examples/04_GPTOSS120B_Example/Readme.md b/examples/04_GPTOSS120B_Example/Readme.md index f9666a1bf..68be9906c 100644 --- a/examples/04_GPTOSS120B_Example/Readme.md +++ b/examples/04_GPTOSS120B_Example/Readme.md @@ -49,8 +49,7 @@ docker run --runtime nvidia --gpus all \ ```bash uv run inference-endpoint benchmark from-config \ - -c examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml \ - --timeout 60 + -c examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml ``` The config uses `api_type: openai_completions`, which routes to `/v1/completions` with pre-tokenized @@ -171,8 +170,7 @@ LiveCodeBench accuracy at concurrency 512: ```bash uv run inference-endpoint benchmark from-config \ - -c examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml \ - --timeout 60 + -c examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml ``` For a performance-only run, use [`gptoss_120b_example.yaml`](gptoss_120b_example.yaml). It is @@ -210,7 +208,6 @@ cd examples/04_GPTOSS120B_Example python run.py \ --report-dir ./results \ --num-repeats 1 \ - --min-duration 10 \ --max-duration 600 ``` @@ -218,7 +215,6 @@ python run.py \ | -------------------- | ------------------------ | ------------------------------------ | | `--report-dir` | `sglang_accuracy_report` | Directory to save results | | `--num-repeats` | `1` | Repeats per dataset | -| `--min-duration` | `10` | Minimum benchmark duration (seconds) | | `--max-duration` | `600` | Maximum benchmark duration (seconds) | | `--force-regenerate` | off | Force dataset regeneration | diff --git a/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml b/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml index 0bd3a2571..cbaeea2b3 100644 --- a/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml +++ b/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml @@ -17,8 +17,8 @@ datasets: settings: runtime: - min_duration_ms: 300 - max_duration_ms: 6000 + max_duration_ms: 6000 # 6 s cap on the performance phase (short smoke run) + # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/04_GPTOSS120B_Example/run.py b/examples/04_GPTOSS120B_Example/run.py index 79b2e7cc3..9e8f43001 100644 --- a/examples/04_GPTOSS120B_Example/run.py +++ b/examples/04_GPTOSS120B_Example/run.py @@ -111,7 +111,6 @@ def run_benchmark_session( rt_settings = RuntimeSettings( metric_target=metrics.Throughput(6), reported_metrics=[], - min_duration_ms=args.min_duration * 1000, max_duration_ms=args.max_duration * 1000, n_samples_from_dataset=0, n_samples_to_issue=0, @@ -265,12 +264,6 @@ def main(): ) # Benchmark configuration arguments - parser.add_argument( - "--min-duration", - type=int, - default=10, - help="Minimum duration in seconds (default: 10)", - ) parser.add_argument( "--max-duration", type=int, diff --git a/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml b/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml index 5a2d2050e..650a95648 100644 --- a/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml +++ b/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml @@ -1,7 +1,6 @@ name: "gpt-oss-120b-benchmark" version: "1.0" type: "online" -timeout: 60 model_params: name: "openai/gpt-oss-120b" @@ -39,8 +38,8 @@ datasets: num_repeats: 5 settings: runtime: - min_duration_ms: 3000 - max_duration_ms: 60000 + max_duration_ms: 60000 # 1 minute cap on the performance phase + # Sample count: perf dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml index 2780e0b49..e4a19063c 100644 --- a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml +++ b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml @@ -1,7 +1,6 @@ name: "gpt-oss-120b-benchmark" version: "1.0" type: "online" -timeout: 60 model_params: name: "openai/gpt-oss-120b" @@ -42,8 +41,8 @@ datasets: settings: runtime: - min_duration_ms: 3000 - max_duration_ms: 60000 + max_duration_ms: 60000 # 1 minute cap on the performance phase + # Sample count: perf dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml index 2a944a00b..351537fcd 100644 --- a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml +++ b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml @@ -16,7 +16,6 @@ name: "gpt-oss-120b-per-dataset-osl" version: "1.0" type: "online" -timeout: 9000 model_params: name: "openai/gpt-oss-120b" @@ -64,8 +63,7 @@ datasets: settings: runtime: - min_duration_ms: 30000 - max_duration_ms: 14400000 # 4h whole-session deadline (perf + all accuracy phases) + max_duration_ms: 14400000 # 4h cap on the performance phase only scheduler_random_seed: 42 dataloader_random_seed: 42 n_samples_to_issue: 2000 # PERF phase only; accuracy phases issue their own sample counts @@ -74,9 +72,11 @@ settings: type: "concurrency" target_concurrency: 1024 + timeouts: + worker_initialization_timeout_s: 300.0 + client: num_workers: 16 - worker_initialization_timeout: 300.0 log_level: "WARN" worker_gc_mode: "disabled" diff --git a/examples/05_Llama_Examples/README.md b/examples/05_Llama_Examples/README.md index f94566541..5bb795c5d 100644 --- a/examples/05_Llama_Examples/README.md +++ b/examples/05_Llama_Examples/README.md @@ -44,13 +44,13 @@ docker run --runtime nvidia --gpus all \ ### Offline mode ```bash -uv run inference-endpoint benchmark from-config -c offline_llama3_8b_cnn.yaml --timeout 600 +uv run inference-endpoint benchmark from-config -c offline_llama3_8b_cnn.yaml ``` ### Online mode ```bash -uv run inference-endpoint benchmark from-config -c online_llama3_8b_cnn.yaml --timeout 600 +uv run inference-endpoint benchmark from-config -c online_llama3_8b_cnn.yaml ``` These configs run in performance-only mode by default. To also evaluate summarization quality, add `--mode both` and install the accuracy dependencies listed in the [Llama-2-70b accuracy setup](#accuracy-evaluation-setup-optional) section below. @@ -104,5 +104,5 @@ docker run --runtime nvidia --gpus all \ ### Online mode ```bash -uv run inference-endpoint benchmark from-config -c online_llama2_70b_orca.yaml --timeout 600 +uv run inference-endpoint benchmark from-config -c online_llama2_70b_orca.yaml ``` diff --git a/examples/05_Llama_Examples/offline_llama3_8b_cnn.yaml b/examples/05_Llama_Examples/offline_llama3_8b_cnn.yaml index 57e105c76..c08e17f21 100644 --- a/examples/05_Llama_Examples/offline_llama3_8b_cnn.yaml +++ b/examples/05_Llama_Examples/offline_llama3_8b_cnn.yaml @@ -28,8 +28,7 @@ datasets: settings: runtime: - min_duration_ms: 60000 # 1 minute - max_duration_ms: 360000 # 6 minutes (Arbitrary here, and doesn't have counterpart in legacy loadgen) + max_duration_ms: 360000 # 6 minute cap on the performance phase (Arbitrary here, and doesn't have counterpart in legacy loadgen) scheduler_random_seed: 137 # For Poisson/distribution sampling dataloader_random_seed: 111 # For dataset shuffling (Will be updated after rng seeds are finalized for submission) n_samples_to_issue: 13368 # Number of samples to issue (for offline, this should match the dataset samples) diff --git a/examples/05_Llama_Examples/online_llama2_70b_orca.yaml b/examples/05_Llama_Examples/online_llama2_70b_orca.yaml index 5a7f6ce53..3c227111d 100644 --- a/examples/05_Llama_Examples/online_llama2_70b_orca.yaml +++ b/examples/05_Llama_Examples/online_llama2_70b_orca.yaml @@ -22,8 +22,8 @@ datasets: settings: runtime: - min_duration_ms: 60000 # 1 minute - max_duration_ms: 600000 # 10 minutes + max_duration_ms: 600000 # 10 minute cap on the performance phase + # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling diff --git a/examples/05_Llama_Examples/online_llama3_8b_cnn.yaml b/examples/05_Llama_Examples/online_llama3_8b_cnn.yaml index 66861f2f5..82de8f7bc 100644 --- a/examples/05_Llama_Examples/online_llama3_8b_cnn.yaml +++ b/examples/05_Llama_Examples/online_llama3_8b_cnn.yaml @@ -28,8 +28,7 @@ datasets: settings: runtime: - min_duration_ms: 600000 # 10 minutes - max_duration_ms: 3600000 # 60 minutes (Arbitrary here, and doesn't have counterpart in legacy loadgen) + max_duration_ms: 3600000 # 60 minute cap on the performance phase (Arbitrary here, and doesn't have counterpart in legacy loadgen) scheduler_random_seed: 137 # For Poisson/distribution sampling dataloader_random_seed: 111 # For dataset shuffling (Will be updated after rng seeds are finalized for submission) n_samples_to_issue: 13368 diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml index d28162dc0..bd8cb0854 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml @@ -3,7 +3,6 @@ name: "interactive-qwen3-vl-235b-a22b-shopify-8k-benchmark" version: "1.0" type: "online" -timeout: 1800 # 30 minutes for quick interactive runs model_params: name: "Qwen/Qwen3-VL-235B-A22B-Instruct" @@ -23,7 +22,7 @@ datasets: settings: runtime: - min_duration_ms: 600000 # 10 minute + # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 @@ -38,7 +37,10 @@ settings: recv_buffer_size: 16777216 send_buffer_size: 16777216 max_connections: 1000 - worker_initialization_timeout: 120 + + timeouts: + # Increase for slow worker startup (spawn, imports). Default 60s may be too short. + worker_initialization_timeout_s: 120 warmup: enabled: true # Enable warmup phase before performance run diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml index 2a1bd203f..5b2660af9 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml @@ -3,7 +3,6 @@ name: "offline-qwen3-vl-235b-a22b-shopify-benchmark" version: "1.0" type: "offline" -timeout: 14400 # Perf + acc run takes over 3 hours, consider limit n_samples_to_issue for perf run or remove accuracy dataset to skip accuracy run model_params: name: "Qwen/Qwen3-VL-235B-A22B-Instruct" @@ -22,7 +21,7 @@ datasets: settings: runtime: - min_duration_ms: 600000 # 10 minutes, this is override when n_samples_to_issue is set + # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling @@ -36,12 +35,11 @@ settings: recv_buffer_size: 16777216 send_buffer_size: 16777216 max_connections: 1000 - # Increase timeout for slow worker startup (spawn, imports). Default 40s may be too short. - worker_initialization_timeout: 120 - drain: - warmup_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely) + timeouts: + # Increase for slow worker startup (spawn, imports). Default 60s may be too short. + worker_initialization_timeout_s: 120 + performance_drain_timeout_s: null # Performance drain timeout in seconds (null = wait indefinitely) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (null = wait indefinitely) warmup: enabled: true # Enable warmup phase before performance run n_requests: 1600 # Warmup request count (None = full dataset once) diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml index e95d142d5..9b9110ae9 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml @@ -2,7 +2,6 @@ name: "online-qwen3-vl-235b-a22b-shopify-benchmark" version: "1.0" type: "online" -timeout: 14400 model_params: name: "Qwen/Qwen3-VL-235B-A22B-Instruct" @@ -22,7 +21,7 @@ datasets: settings: runtime: - min_duration_ms: 600000 # 10 minutes, this is override when n_samples_to_issue is set + # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 @@ -37,8 +36,10 @@ settings: recv_buffer_size: 16777216 send_buffer_size: 16777216 max_connections: 1000 - # Increase timeout for slow worker startup (spawn, imports). Default 40s may be too short. - worker_initialization_timeout: 120 + + timeouts: + # Increase for slow worker startup (spawn, imports). Default 60s may be too short. + worker_initialization_timeout_s: 120 warmup: enabled: true # Enable warmup phase before performance run n_requests: 1600 # Warmup request count (None = full dataset once) diff --git a/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml b/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml index af50258b8..fd7ed1afa 100644 --- a/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml +++ b/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml @@ -50,9 +50,9 @@ audit: settings: runtime: - # NOTE: runs are count-driven (n_samples_to_issue / audit.samples). min_duration_ms is - # NOT enforced as a duration floor by the current stop logic (counts take priority); - # MLCommons' 10-min minimum / AND-semantics is future work. Only max_duration_ms caps. + # NOTE: runs are count-driven (n_samples_to_issue / audit.samples); there is no duration + # floor — MLCommons' 10-min minimum / AND-semantics is future work. max_duration_ms only + # caps the performance phase. max_duration_ms: 14400000 # 4-hour ceiling scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml b/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml index bec6f8720..de4c534e5 100644 --- a/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml +++ b/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml @@ -50,9 +50,9 @@ audit: settings: runtime: - # NOTE: runs are count-driven (n_samples_to_issue / audit counts). min_duration_ms is - # NOT enforced as a duration floor by the current stop logic (counts take priority); - # MLCommons' 10-min minimum / AND-semantics is future work. Only max_duration_ms caps. + # NOTE: runs are count-driven (n_samples_to_issue / audit counts); there is no duration + # floor — MLCommons' 10-min minimum / AND-semantics is future work. max_duration_ms only + # caps the performance phase. max_duration_ms: 7200000 # 2-hour ceiling scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/10_Agentic_Inference/kimi_agentic_benchmark.yaml b/examples/10_Agentic_Inference/kimi_agentic_benchmark.yaml index 1aa61f6cd..67d7d69d3 100644 --- a/examples/10_Agentic_Inference/kimi_agentic_benchmark.yaml +++ b/examples/10_Agentic_Inference/kimi_agentic_benchmark.yaml @@ -37,11 +37,8 @@ datasets: settings: runtime: - min_duration_ms: 0 - max_duration_ms: 0 scheduler_random_seed: 42 dataloader_random_seed: 42 - load_pattern: type: agentic_inference target_concurrency: 8 # Submission-specific concurrency. diff --git a/examples/10_Agentic_Inference/qwen_agentic_benchmark.yaml b/examples/10_Agentic_Inference/qwen_agentic_benchmark.yaml index 562742c24..d183e4bfa 100644 --- a/examples/10_Agentic_Inference/qwen_agentic_benchmark.yaml +++ b/examples/10_Agentic_Inference/qwen_agentic_benchmark.yaml @@ -39,8 +39,7 @@ datasets: settings: runtime: - min_duration_ms: 0 - max_duration_ms: 36000000 + max_duration_ms: 36000000 # 10-hour cap on the performance phase load_pattern: type: agentic_inference diff --git a/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml b/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml index 60256d6af..b91ab2ac4 100644 --- a/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml +++ b/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml @@ -14,8 +14,7 @@ # Phases run perf -> accuracy (framework order). Both are deterministic # (temperature 0, seed 42) against a reasoning-off server, so order does not # affect results. Total wall-clock ~5.5 h on a single-stream edge box -# (e.g. NVIDIA Jetson AGX Thor, Qwen3.6-27B Q4_K_M, reasoning off); `timeout` -# below is sized to 6 h. +# (e.g. NVIDIA Jetson AGX Thor, Qwen3.6-27B Q4_K_M, reasoning off). # # Requires: pip install -e ".[bfcl]" (the BFCL accuracy dataset pulls bfcl-eval) # @@ -33,7 +32,6 @@ name: "edge-agentic-full-run" version: "1.0" type: "online" -timeout: 21600 # 6 h: ~2.5 h perf + ~3 h accuracy, with headroom. model_params: name: "Qwen3.6-27B-Q4_K_M" # set to your served model name. @@ -91,7 +89,6 @@ datasets: settings: runtime: - min_duration_ms: 0 # Safety cap (4 h) so the performance phase stays bounded even if decode is # slower than expected; one pass should finish in ~2.5 h on an edge box. max_duration_ms: 14400000 diff --git a/scripts/bench_drain_tokenize.py b/scripts/bench_drain_tokenize.py new file mode 100644 index 000000000..5a0ca00d0 --- /dev/null +++ b/scripts/bench_drain_tokenize.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Apples-to-apples benchmark of OUTPUT-tokenization strategies for the +metrics-aggregator drain (OSL / TPOT). + +Why this exists: at the end of a run the aggregator tokenizes every sample's +output to derive OSL/TPOT. The live impl fires one asyncio task per sample, +each awaiting ``loop.run_in_executor(thread_pool, len(tok.tokenize(text)))`` +(see ``metrics_aggregator/metrics_table.py::AsyncTokenTrigger.fire`` + +``token_metrics.py::TokenizePool.token_count_async``). This script reproduces +that exact pattern standalone and pits it against a single batched +``tokenizer(texts)`` call (the batched strategy from the prior ISL ablation) +so the cost of the current design — and the win from replacing it — is measured +on identical inputs. Measured (Qwen2.5-0.5B, 48-core, 12 workers): encode_batch +is ~4.6x the current per-sample async pattern on short outputs, ~2.0x on the +realistic right-skewed OSL distribution (mean ~3.8k tok) — and, more +importantly, removes the per-sample asyncio-task backlog (1 task/sample) that +drives the drain timeout. The single batched Rust call beats thread-sharding +(the HF fast tokenizer already parallelises a batch internally). + +Strategies (all plain ``tokenize``, no chat template — matches the OSL/TPOT +text path taken when the output has no tool_calls): + + current_async EXACT live drain pattern: per-sample loop.create_task -> + TokenizePool.token_count_async -> run_in_executor, gathered. + sync_loop Serial ``len(tok.tokenize(t))`` — isolates raw tokenize cost + from asyncio/thread-pool overhead. + batch One ``tokenizer(texts)`` Rust call over all texts. + thread_batch Shard texts across ``--workers`` threads, each batch-tokenizes + its shard (GIL released inside the Rust call). + +Usage: + uv run python scripts/bench_drain_tokenize.py \ + --model Qwen/Qwen2.5-0.5B-Instruct --n-samples 20000 --runs 3 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import random +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from inference_endpoint.async_utils.services.metrics_aggregator.token_metrics import ( + TokenizePool, +) +from transformers import AutoTokenizer + +_WORDS = ( + "the quick brown fox jumps over the lazy dog inference benchmark " + "tokenization latency throughput performance model weights attention " + "transformer layer norm softmax gradient embedding sequence decode " +).split() + + +# Measured OSL token-length distribution (max_new_tokens=20000 cap; heavily +# right-skewed: median 2153, mean 3824). Piecewise-linear inverse-CDF from the +# measured percentiles so generated lengths match the real drain workload. +_OSL_PCTL: tuple[tuple[float, int], ...] = ( + (0, 177), + (1, 303), + (5, 463), + (10, 578), + (25, 951), + (50, 2153), + (75, 4977), + (80, 6001), + (90, 9564), + (95, 13510), + (97, 16422), + (99, 20000), + (100, 20000), +) + + +def _sample_osl(rng: random.Random) -> int: + p = rng.random() * 100.0 + for (p0, v0), (p1, v1) in zip(_OSL_PCTL, _OSL_PCTL[1:], strict=False): + if p <= p1: + frac = (p - p0) / (p1 - p0) if p1 > p0 else 0.0 + return int(v0 + frac * (v1 - v0)) + return _OSL_PCTL[-1][1] + + +def _make_outputs( + n: int, profile: str, min_words: int, max_words: int, seed: int = 42 +) -> list[str]: + """Synthetic model-output texts (plain text, the OSL/TPOT common case). + + profile='mlperf' draws word counts from the measured OSL distribution + (token≈word for these common words); 'uniform' uses [min_words, max_words]. + """ + rng = random.Random(seed) + if profile == "mlperf": + lengths = [_sample_osl(rng) for _ in range(n)] + else: + lengths = [rng.randint(min_words, max_words) for _ in range(n)] + return [" ".join(rng.choices(_WORDS, k=length)) for length in lengths] + + +def _result(name: str, secs: float, n: int, total_tokens: int) -> dict[str, Any]: + return { + "strategy": name, + "wall_s": round(secs, 4), + "samples_per_s": round(n / secs) if secs else 0, + "tokens_per_s": round(total_tokens / secs) if secs else 0, + } + + +def bench_sync_loop(texts: list[str], tok: Any) -> tuple[float, int]: + t0 = time.perf_counter() + total = 0 + for t in texts: + total += len(tok.tokenize(t)) + return time.perf_counter() - t0, total + + +def bench_batch(texts: list[str], tok: Any) -> tuple[float, int]: + t0 = time.perf_counter() + enc = tok(texts, add_special_tokens=False, return_attention_mask=False) + total = sum(len(ids) for ids in enc["input_ids"]) + return time.perf_counter() - t0, total + + +def bench_encode_batch(texts: list[str], tok: Any) -> tuple[float, int]: + """Raw Rust ``encode_batch`` on the backend tokenizer — skips the + BatchEncoding/padding wrapper that ``tokenizer(...)`` builds. We only need + counts, so this is the leanest count-only path.""" + backend = tok.backend_tokenizer + # encode_batch_fast (tokenizers>=0.20) skips offset computation; fall back + # to encode_batch where unavailable. + fn = getattr(backend, "encode_batch_fast", None) or backend.encode_batch + t0 = time.perf_counter() + encs = fn(texts, add_special_tokens=False) + total = sum(len(e.ids) for e in encs) + return time.perf_counter() - t0, total + + +def bench_batch_chunked( + texts: list[str], tok: Any, chunk: int = 50_000 +) -> tuple[float, int]: + """Chunked batches — bounds peak memory for very large drains while still + feeding the Rust parallel path large slices.""" + t0 = time.perf_counter() + total = 0 + for i in range(0, len(texts), chunk): + enc = tok( + texts[i : i + chunk], + add_special_tokens=False, + return_attention_mask=False, + ) + total += sum(len(ids) for ids in enc["input_ids"]) + return time.perf_counter() - t0, total + + +def bench_thread_batch( + texts: list[str], tokenizer_name: str, workers: int +) -> tuple[float, int]: + # Each worker loads its own tokenizer (thread-local, like TokenizePool) and + # batch-tokenizes a contiguous shard. + shards: list[list[str]] = [texts[i::workers] for i in range(workers)] + tls = threading.local() + + def _work_tls(shard: list[str]) -> int: + tok = getattr(tls, "tok", None) + if tok is None: + tok = AutoTokenizer.from_pretrained(tokenizer_name) + tls.tok = tok + if not shard: + return 0 + enc = tok(shard, add_special_tokens=False, return_attention_mask=False) + return sum(len(ids) for ids in enc["input_ids"]) + + with ThreadPoolExecutor(max_workers=workers) as ex: + # Warm tokenizers on every thread before timing. + list(ex.map(lambda _: _work_tls([]), range(workers))) + t0 = time.perf_counter() + total = sum(ex.map(_work_tls, shards)) + return time.perf_counter() - t0, total + + +async def bench_current_async( + texts: list[str], pool: TokenizePool +) -> tuple[float, int]: + """EXACT live drain pattern: one asyncio task per sample, each awaiting + pool.token_count_async (-> loop.run_in_executor), then gathered.""" + loop = asyncio.get_running_loop() + t0 = time.perf_counter() + tasks = [loop.create_task(pool.token_count_async(t, loop)) for t in texts] + counts = await asyncio.gather(*tasks) + return time.perf_counter() - t0, sum(counts) + + +def _run_current_async(texts: list[str], pool: TokenizePool) -> tuple[float, int]: + try: + import uvloop # the aggregator runs on uvloop; match it. + + runner = uvloop.run + except ImportError: + runner = asyncio.run + return runner(bench_current_async(texts, pool)) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--model", default="Qwen/Qwen2.5-0.5B-Instruct") + ap.add_argument("--n-samples", type=int, default=20000) + ap.add_argument("--runs", type=int, default=3) + ap.add_argument( + "--workers", + type=int, + default=max(2, (os.cpu_count() or 16) // 4), + help="TokenizePool / thread_batch worker count (aggregator default).", + ) + ap.add_argument("--osl-profile", choices=("mlperf", "uniform"), default="mlperf") + ap.add_argument("--min-words", type=int, default=20) + ap.add_argument("--max-words", type=int, default=200) + ap.add_argument("--output", default="") + args = ap.parse_args() + + print(f"Loading tokenizer: {args.model}") + AutoTokenizer.from_pretrained(args.model) # warm cache before timing + tok = AutoTokenizer.from_pretrained(args.model) + + print( + f"Generating {args.n_samples} synthetic outputs (profile={args.osl_profile})..." + ) + texts = _make_outputs( + args.n_samples, args.osl_profile, args.min_words, args.max_words + ) + avg_words = sum(t.count(" ") + 1 for t in texts) / len(texts) + print( + f"profile={args.osl_profile} | avg {avg_words:.0f} words/output " + f"| workers={args.workers}\n" + ) + + pool = TokenizePool(args.model, n_workers=args.workers) + results: list[dict[str, Any]] = [] + try: + strategies = [ + ("current_async", lambda: _run_current_async(texts, pool)), + ("sync_loop", lambda: bench_sync_loop(texts, tok)), + ("batch", lambda: bench_batch(texts, tok)), + ("batch_chunked", lambda: bench_batch_chunked(texts, tok)), + ("encode_batch", lambda: bench_encode_batch(texts, tok)), + ( + "thread_batch", + lambda: bench_thread_batch(texts, args.model, args.workers), + ), + ] + for name, fn in strategies: + best_secs = float("inf") + total_tokens = 0 + for _ in range(args.runs): + secs, total_tokens = fn() + best_secs = min(best_secs, secs) + r = _result(name, best_secs, args.n_samples, total_tokens) + results.append(r) + print( + f"{name:<16} {r['wall_s']:>9.4f}s " + f"{r['samples_per_s']:>12,} samples/s " + f"{r['tokens_per_s']:>14,} tok/s" + ) + finally: + pool.close() + + base = next(r for r in results if r["strategy"] == "current_async") + print("\nspeedup vs current_async (best wall):") + for r in results: + if r["strategy"] != "current_async" and r["samples_per_s"]: + print( + f" {r['strategy']:<16} {r['samples_per_s'] / base['samples_per_s']:>6.1f}x" + ) + + if args.output: + with open(args.output, "w") as f: + json.dump({"args": vars(args), "results": results}, f, indent=2) + print(f"\nwrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/regenerate_templates.py b/scripts/regenerate_templates.py index 5d72668fe..634fcb671 100644 --- a/scripts/regenerate_templates.py +++ b/scripts/regenerate_templates.py @@ -371,8 +371,6 @@ def _build_minimal(test_type: TestType, overrides: dict) -> dict: "datasets": [PERF_DATASET], "settings": { "runtime": { - "min_duration_ms": 600000, - "max_duration_ms": 0, "n_samples_to_issue": None, }, }, diff --git a/src/inference_endpoint/async_utils/services/launcher.py b/src/inference_endpoint/async_utils/services/launcher.py index f1e1dac15..8cce48666 100644 --- a/src/inference_endpoint/async_utils/services/launcher.py +++ b/src/inference_endpoint/async_utils/services/launcher.py @@ -69,6 +69,7 @@ class ServiceLauncher: def __init__(self, zmq_context: ManagedZMQContext) -> None: self._zmq_ctx = zmq_context self._procs: list[subprocess.Popen] = [] + self._modules: list[str] = [] @property def procs(self) -> list[subprocess.Popen]: @@ -118,6 +119,7 @@ async def launch( logger.info("Launching service: %s (id=%d)", svc.module, i) proc = subprocess.Popen(cmd) self._procs.append(proc) + self._modules.append(svc.module) await receiver.wait(timeout=timeout) logger.info("All %d services ready", len(services)) @@ -145,6 +147,18 @@ async def launch( # re-raise the exception. raise + def terminate(self, module: str) -> None: + """SIGTERM managed subprocesses whose module exactly matches ``module``. + + Targeted so the whole-run watchdog can abort the metrics aggregator + (whose SIGTERM handler writes an INTERRUPTED final snapshot) without + killing the event logger, which flushes its buffer on the session's + ENDED event and would lose buffered records on SIGTERM. + """ + for launched_module, proc in zip(self._modules, self._procs, strict=True): + if launched_module == module and proc.poll() is None: + proc.terminate() + def terminate_all(self, timeout: float = 5.0) -> None: """Terminate all managed subprocesses: SIGTERM then escalate to SIGKILL. diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/publisher.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/publisher.py index 578e47198..df4cd673c 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/publisher.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/publisher.py @@ -88,10 +88,15 @@ def __init__( self._final_snapshot_path = final_snapshot_path self._tick_task: asyncio.Task | None = None self._closed = False - # publish_final is idempotent: the SIGTERM handler in - # __main__.py and the aggregator's ENDED-driven path can both - # call it; the second call must not re-publish or re-write. + # publish_final is idempotent AND serialized: the SIGTERM handler in + # __main__.py and the aggregator's ENDED-driven path can both call + # it. The lock makes a raced second caller block until the in-flight + # finalize (including the atomic file write) completes before + # early-returning, so the SIGTERM path's shutdown_event.set() can + # never let main() return while the write is still in flight + # (which would abandon a .tmp and leave no final_snapshot.json). self._finalized = False + self._final_lock = asyncio.Lock() # ------------------------------------------------------------------ # Live tick task @@ -189,13 +194,28 @@ async def publish_final( (which would let a conflate-mode TUI see the live tick instead of the terminal state as the last message). - Idempotent: only the first call writes/publishes; subsequent - calls early-return. The SIGTERM handler relies on this to - race safely with the ENDED-driven path. + Idempotent and serialized: only the first call writes/publishes; + a concurrent second call blocks until the first finishes, then + early-returns. The SIGTERM handler relies on this to race safely + with the ENDED-driven path — its ``shutdown_event.set()`` cannot + run before an in-flight finalize write has completed. """ - if self._finalized: - return - self._finalized = True + async with self._final_lock: + if self._finalized: + return + self._finalized = True + await self._publish_final_locked( + registry, n_pending_tasks=n_pending_tasks, interrupted=interrupted + ) + + async def _publish_final_locked( + self, + registry: MetricsRegistry, + *, + n_pending_tasks: int, + interrupted: bool, + ) -> None: + """Finalize body; runs exactly once, under ``_final_lock``.""" if self._tick_task is not None: self._tick_task.cancel() try: diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index 1b8eddca1..9df903e26 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -129,6 +129,13 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: report = bench.report if report is None: raise ExecutionError(f"Audit phase '{spec.label}' produced no report") + # A timed-out phase produced an INTERRUPTED report at best; certifying + # a compliance result from it is never valid. + if bench.run_timed_out: + raise ExecutionError( + f"Audit phase '{spec.label}' hit the run timeout " + "(settings.timeouts.run_timeout_s); report marked INTERRUPTED" + ) # A SIGINT/SIGTERM during a (long) audit phase is turned into a graceful # stop, so the phase returns with an "interrupted" report. Propagate it # as KeyboardInterrupt so the CLI exits 130 (interrupted), not as a diff --git a/src/inference_endpoint/commands/benchmark/cli.py b/src/inference_endpoint/commands/benchmark/cli.py index 0edb4da79..68aaa0f1e 100644 --- a/src/inference_endpoint/commands/benchmark/cli.py +++ b/src/inference_endpoint/commands/benchmark/cli.py @@ -174,7 +174,15 @@ def from_config( except (yaml.YAMLError, ValidationError, ValueError, FileNotFoundError) as e: raise InputValidationError(f"Config error: {e}") from e if timeout is not None: - resolved = resolved.with_updates(timeout=timeout) + resolved = resolved.with_updates( + settings=resolved.settings.model_copy( + update={ + "timeouts": resolved.settings.timeouts.with_updates( + run_timeout_s=timeout + ) + } + ) + ) if report_dir is not None: resolved = resolved.with_updates(report_dir=report_dir) test_mode = mode or ( diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 5bf0564e8..edef04523 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -34,6 +34,7 @@ import shutil import signal import tempfile +import time import uuid from collections.abc import Callable from dataclasses import dataclass, field @@ -162,6 +163,7 @@ class BenchmarkResult: # settings.profiling.engine is set; None otherwise. Rendered into # report.txt and a sibling profiling.json by finalize_benchmark. profiling: dict[str, Any] | None = None + run_timed_out: bool = False @dataclass @@ -528,9 +530,7 @@ def setup_benchmark( f"Mode: {test_mode}, Target QPS: {config.settings.load_pattern.target_qps}, Responses: {collect_responses}" ) if rt_settings is not None: - logger.info( - f"Min Duration: {rt_settings.min_duration_ms / 1000:.1f}s, Expected samples: {total_samples}" - ) + logger.info(f"Expected samples: {total_samples}") else: logger.info(f"Accuracy-only mode, Expected samples: {total_samples}") for ec in eval_configs: @@ -563,7 +563,7 @@ def _build_phases( ) -> list[PhaseConfig]: """Build the phase list from BenchmarkContext.""" phases: list[PhaseConfig] = [] - drain_cfg = ctx.config.settings.drain + timeouts = ctx.config.settings.timeouts if ctx.dataloader is not None and ctx.rt_settings is not None: perf_dataset = next( @@ -603,7 +603,7 @@ def _build_phases( warmup_dataset, PhaseType.WARMUP, drain_after=warmup_cfg.drain, - drain_timeout=drain_cfg.warmup_timeout_s, + drain_timeout=timeouts.warmup_drain_timeout_s, ) ) @@ -614,7 +614,7 @@ def _build_phases( ctx.dataloader, PhaseType.PERFORMANCE, strategy=perf_strategy, - drain_timeout=drain_cfg.performance_timeout_s, + drain_timeout=timeouts.performance_drain_timeout_s, routing_headers=routing_headers, ) ) @@ -669,7 +669,7 @@ def _build_phases( acc_settings, acc_ds, PhaseType.ACCURACY, - drain_timeout=drain_cfg.accuracy_timeout_s, + drain_timeout=timeouts.accuracy_drain_timeout_s, ) ) @@ -721,6 +721,7 @@ async def _create_issuer( api_type: APIType = config.endpoint_config.api_type # client.api_type is propagated from endpoint_config.api_type by # BenchmarkConfig._propagate_client_api_type — no override needed here. + timeouts = config.settings.timeouts client_overrides: dict = { "endpoint_urls": [ urljoin(e.rstrip("/") + "/", api_type.default_route()) @@ -729,6 +730,12 @@ async def _create_issuer( "api_key": config.endpoint_config.api_key, "event_logs_dir": ctx.report_dir, "cpu_affinity": ctx.affinity_plan, + # Worker lifecycle deadlines live in settings.timeouts; the + # HTTPClientConfig fields are excluded runtime carriers populated + # only here. + "worker_initialization_timeout_s": timeouts.worker_initialization_timeout_s, + "worker_graceful_shutdown_wait_s": timeouts.worker_graceful_shutdown_wait_s, + "worker_force_kill_timeout_s": timeouts.worker_force_kill_timeout_s, } if ctx.accuracy_only: # Single-stream (num_workers=1, max_connections=1) is baked into @@ -799,6 +806,8 @@ def _on_sample_complete(result: QueryResult) -> None: async def _run_benchmark_async( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop, + *, + deadline: float | None = None, ) -> BenchmarkResult: """Run async benchmark session.""" config = ctx.config @@ -840,6 +849,45 @@ async def _run_benchmark_async( # idempotent, so the clean-path shutdown below is a harmless second call. http_client: HTTPEndpointClient | None = None + # Whole-run watchdog. Armed before the pipeline starts so setup stalls + # (service launch, endpoint connect) are bounded too, and kept armed + # through the metrics drain so run_timeout_s can SIGTERM a stuck + # aggregator drain. Cancelled in the outermost finally. + run_timed_out = False + # The session is created later inside the pipeline scope; bind it through + # a mutable holder so the callback never touches a possibly-unbound local + # (a NameError inside a loop callback is swallowed by the loop's exception + # handler, which would leave the watchdog inert). + session_ref: list[BenchmarkSession] = [] + run_timeout_s = config.settings.timeouts.run_timeout_s + + def _on_run_timeout() -> None: + nonlocal run_timed_out + run_timed_out = True + logger.error( + "Run timeout (%.1fs) reached; aborting run — report will be " + "marked INTERRUPTED.", + run_timeout_s, + ) + # Stop the session first: it short-circuits _drain_inflight and + # run()'s finally publishes ENDED promptly, so the aggregator still + # records the buffered tokenizer-drain samples. Then SIGTERM the + # aggregator: its handler writes the INTERRUPTED final snapshot + # (publish_final is first-wins, so INTERRUPTED stays authoritative; + # even if a still-draining aggregator finalizes as COMPLETE first, + # run_benchmark raises on run_timed_out, so a timed-out run always + # fails loudly). Targeted (not all services): the event logger + # flushes on ENDED, which session.stop() still delivers. + if session_ref: + session_ref[0].stop() + pipe.terminate_metrics_aggregator() + + run_watchdog = ( + loop.call_later(max(0.0, deadline - time.monotonic()), _on_run_timeout) + if deadline is not None + else None + ) + try: tmpfs_dir.mkdir(parents=True, exist_ok=True) event_log_dir.mkdir(parents=True, exist_ok=True) @@ -876,6 +924,7 @@ async def _run_benchmark_async( on_sample_complete=on_sample_complete, session_id=session_id, ) + session_ref.append(session) phases = _build_phases(ctx, perf_strategy=agentic_inference_strategy) max_duration_ms = ( @@ -915,18 +964,48 @@ def _on_phase_start(phase: PhaseConfig) -> None: loop.add_signal_handler(signal.SIGINT, session.stop) try: - result = await session.run(phases, on_phase_start=_on_phase_start) - session_completed_normally = True + if run_timed_out: + # Deadline elapsed during setup — never start issuing + # load after it. Run the already-stopped session so + # STARTED/ENDED still flow: the event logger exits only + # on ENDED, and the drain below waits for it. Zero + # samples issue; the INTERRUPTED artifacts still get + # written. + session.stop() + result = await session.run(phases) + else: + result = await session.run( + phases, on_phase_start=_on_phase_start + ) + session_completed_normally = True except Exception as e: - raise ExecutionError(f"Benchmark execution failed: {e}") from e + if run_timed_out: + # The watchdog already aborted the run; a teardown race + # can surface here as a generic exception. Fall through + # with an empty session result so finalize still writes + # the INTERRUPTED report artifacts — run_benchmark + # raises the timeout ExecutionError after finalization. + logger.exception( + "Session error after run timeout fired " + "(continuing to finalize)" + ) + result = SessionResult( + session_id=session_id, + phase_results=[], + start_time_ns=0, + end_time_ns=0, + ) + else: + raise ExecutionError(f"Benchmark execution failed: {e}") from e finally: _timeout_done = True perf_timeout.cancel() loop.remove_signal_handler(signal.SIGINT) # Fire /stop_profile for URLs whose /start_profile succeeded. # Unifies the clean phase-end path and the abort path — both - # reach this block. - profiler.stop(session_completed_normally) + # reach this block. A watchdog abort counts as an abort even + # when session.run returned normally after session.stop(). + profiler.stop(session_completed_normally and not run_timed_out) # Graceful drain runs on both the clean-finish and session- # failure paths (BenchmarkSession.run publishes ENDED in its own # finally, so a failed run still has a terminal snapshot worth @@ -979,6 +1058,9 @@ def _on_phase_start(phase: PhaseConfig) -> None: "Failed to salvage tmpfs: %s — tmpfs retained at %s", e, tmpfs_dir ) raise + finally: + if run_watchdog is not None: + run_watchdog.cancel() return BenchmarkResult( session=result, @@ -986,13 +1068,25 @@ def _on_phase_start(phase: PhaseConfig) -> None: report=report, tmpfs_dir=tmpfs_dir, profiling=profiler.payload(), + run_timed_out=run_timed_out, ) -def run_benchmark_async(ctx: BenchmarkContext) -> BenchmarkResult: - """Run async benchmark. Sync entry point — drives the event loop.""" +def run_benchmark_async( + ctx: BenchmarkContext, *, deadline: float | None = None +) -> BenchmarkResult: + """Run async benchmark. Sync entry point — drives the event loop. + + When ``deadline`` is None and ``settings.timeouts.run_timeout_s`` is set, + computes its own deadline at entry, so each audit phase gets a full + per-phase budget. + """ + if deadline is None: + run_timeout_s = ctx.config.settings.timeouts.run_timeout_s + if run_timeout_s is not None: + deadline = time.monotonic() + run_timeout_s loop = LoopManager().default_loop - return loop.run_until_complete(_run_benchmark_async(ctx, loop)) + return loop.run_until_complete(_run_benchmark_async(ctx, loop, deadline=deadline)) def _write_scoring_artifacts( @@ -1114,6 +1208,11 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: result = bench.session collector = bench.collector report = bench.report + if report is not None and bench.run_timed_out and report.complete: + # Split-brain guard: the aggregator may have finalized COMPLETE before + # the watchdog's SIGTERM landed. A timed-out run must never publish + # complete:true artifacts, so force the flag honest before writing. + report = msgspec.structs.replace(report, complete=False) # Write scoring artifacts + copy event log from tmpfs to disk (scorers read # sample_idx_map.json + events.jsonl from here). @@ -1127,7 +1226,16 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # then the exception propagates as before. accuracy_scores: list[dict[str, Any]] = [] try: - accuracy_scores = score_accuracy(ctx, result) + if bench.run_timed_out: + # Phases may never have started (scorer init KeyErrors on missing + # sample maps) and partial phases would yield misleading subset + # scores; the scoring artifacts above are still on disk for + # inspection. + logger.warning( + "Run timeout fired — skipping accuracy scoring on partial data" + ) + else: + accuracy_scores = score_accuracy(ctx, result) finally: # Attach the per-dataset accuracy list so result_summary.json, the # console summary, and report.txt all carry it (stays [] on a scoring @@ -1171,17 +1279,39 @@ def run_benchmark( a config with an ``audit:`` block, point ``run_audit`` at ``/audit``). The compliance audit is dispatched by the caller (``cli._run``), not here, so this module does not depend on ``commands.audit``. + + The whole-run watchdog deadline is taken at entry, so setup (tokenizer/ + dataset load) counts against ``run_timeout_s``; a hung *synchronous* setup + step itself stays unbounded — the deadline is only checked once setup + returns, and enforced by the event-loop timer thereafter. """ logger.debug( "BenchmarkConfig (%s):\n%s", type(config).__name__, config.model_dump_json(indent=2, exclude_none=True), ) + # Deadline for the whole-run watchdog is taken at entry so setup + # (tokenizer/dataset load) counts against run_timeout_s too. + deadline: float | None = None + run_timeout_s = config.settings.timeouts.run_timeout_s + if run_timeout_s is not None: + deadline = time.monotonic() + run_timeout_s ctx = setup_benchmark(config, test_mode) + if deadline is not None and time.monotonic() >= deadline: + # Setup alone consumed the budget: fail before any services start. + raise ExecutionError( + f"Run timeout ({run_timeout_s}s) reached during setup; " + "no services were started" + ) bench: BenchmarkResult | None = None try: - bench = run_benchmark_async(ctx) + bench = run_benchmark_async(ctx, deadline=deadline) finalize_benchmark(ctx, bench) + if bench.run_timed_out: + raise ExecutionError( + f"Run timeout ({run_timeout_s}s) reached; run aborted and " + "report marked INTERRUPTED" + ) except KeyboardInterrupt: # Salvage results (finally), then propagate to main.py -> exit 130. logger.warning("Benchmark interrupted by user") diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index 7778ac416..bd056ac48 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -100,7 +100,7 @@ def _build_aggregator_args( metrics_output_dir: Path, enable_streaming: bool, tokenizer_name: str | None, - drain_timeout_s: float, + drain_timeout_s: float | None, tokenizer_workers: int, early_stopping: bool, ) -> list[str]: @@ -121,7 +121,11 @@ def _build_aggregator_args( args.append("--early-stopping") if tokenizer_name is not None: args.extend(["--tokenizer", tokenizer_name]) - args.extend(["--drain-timeout", str(drain_timeout_s)]) + # Aggregator argv contract keeps 0 = unlimited (hand-launch default); + # the schema uses None = unlimited, so convert at the argv boundary. + args.extend( + ["--drain-timeout", "0" if drain_timeout_s is None else str(drain_timeout_s)] + ) args.extend(["--tokenizer-workers", str(tokenizer_workers)]) return args @@ -280,7 +284,7 @@ async def start(self) -> None: stack.callback(self._close_subscriber) self._launcher = ServiceLauncher(zmq_ctx) - drain = self._config.settings.drain + timeouts = self._config.settings.timeouts aggregator_args = _build_aggregator_args( socket_dir=zmq_ctx.socket_dir, pub_socket_name=pub_socket_name, @@ -288,8 +292,8 @@ async def start(self) -> None: metrics_output_dir=self._metrics_output_dir, enable_streaming=self._enable_streaming, tokenizer_name=self._tokenizer_name, - drain_timeout_s=drain.metrics_drain_timeout_s, - tokenizer_workers=drain.metrics_tokenizer_workers, + drain_timeout_s=timeouts.metrics_drain_timeout_s, + tokenizer_workers=self._config.settings.metrics_tokenizer_workers, early_stopping=self._config.settings.early_stopping.enabled, ) event_logger_args = _build_event_logger_args( @@ -302,7 +306,7 @@ async def start(self) -> None: ServiceConfig(module=_AGGREGATOR_MODULE, args=aggregator_args), ServiceConfig(module=_EVENT_LOGGER_MODULE, args=event_logger_args), ], - timeout=self._config.settings.service_ready_timeout_s, + timeout=timeouts.service_ready_timeout_s, ) except BaseException as e: if self._launcher is not None: # launch may have spawned children @@ -360,6 +364,17 @@ async def drain_and_build_report(self) -> Report | None: ) return report + def terminate_metrics_aggregator(self) -> None: + """SIGTERM the metrics aggregator; safe no-op before launch. + + Run-watchdog abort path: targeted so the aggregator's SIGTERM handler + writes the INTERRUPTED final snapshot while the event logger stays + alive to flush its buffer on the session's ENDED event. + """ + if self._launcher is None: + return + self._launcher.terminate(_AGGREGATOR_MODULE) + def _kill_services(self) -> None: """Best-effort service termination owned by the pipeline ExitStack. diff --git a/src/inference_endpoint/config/audit.py b/src/inference_endpoint/config/audit.py new file mode 100644 index 000000000..0af28d38a --- /dev/null +++ b/src/inference_endpoint/config/audit.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compliance audit configuration. + +Split criterion: one module per config domain; the audit test registry and its +per-test config models live here. ``config/schema.py`` re-exports the public +surface. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class AuditTestId(str, Enum): + """Registered compliance audit test identifiers.""" + + # Output-caching audit — MLPerf TEST04 (duplicate-query caching detection). + OUTPUT_CACHING_TEST = "output_caching_test" + + +class OutputCachingTestConfig(BaseModel): + """Configuration for the output-caching audit (MLPerf TEST04). + + The output-caching test runs two back-to-back phases — a reference run of + distinct samples and an audit run that repeats one fixed sample — then + checks that the audit QPS does not exceed the reference QPS by more than + ``threshold``. A large speedup indicates the SUT is caching responses. + + samples: reference-phase query count (required — an explicit count keeps + the per-phase completion check meaningful; a duration-driven phase has + no independent target to validate completion against) + audit_samples: audit-phase query count (None → equals samples) + sample_index: which dataset row is repeated (MLCommons performance_issue_same_index) + threshold: tolerance shared by both pass checks — each phase must complete + ≥ requested * (1 - threshold), and audit_qps must stay < ref_qps * (1 + threshold) + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + test: Literal[AuditTestId.OUTPUT_CACHING_TEST] + only: bool = Field( + False, + description="Run only the audit — skip the main benchmark (upstream-style standalone TEST04)", + ) + samples: int = Field(..., ge=1, description="Reference phase query count") + audit_samples: int | None = Field( + None, ge=1, description="Audit phase query count (default: equals samples)" + ) + sample_index: int = Field( + 0, ge=0, description="Dataset row index repeated in the audit phase" + ) + threshold: float = Field( + 0.10, + gt=0, + lt=1, + description=( + "Tolerance for both checks: each phase must complete " + "≥ requested * (1 - threshold), and audit_qps must stay " + "< ref_qps * (1 + threshold)" + ), + ) + + +# Single member today; becomes +# Annotated[OutputCachingTestConfig | ..., Field(discriminator="test")] +# when additional audit tests are added. +AuditConfig = OutputCachingTestConfig diff --git a/src/inference_endpoint/config/datasets.py b/src/inference_endpoint/config/datasets.py new file mode 100644 index 000000000..06fcd4d0a --- /dev/null +++ b/src/inference_endpoint/config/datasets.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dataset configuration models. + +Split criterion: one module per config domain; the dataset models and their +generation-config-override merge helpers live here (the override keys' only +consumer is ``Dataset``, so they stay together). ``config/schema.py`` +re-exports the public surface. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated, Any, Self + +import cyclopts +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .enums import DatasetType, EvalMethod, ScorerMethod +from .model_params import ModelParams + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge ``override`` into ``base`` and return the result. + + For overlapping keys whose values are both dicts, recurse; otherwise the + override value wins. Mutates a *copy* — callers can safely pass model_dump() + output. Used by ``Dataset.effective_generation_config`` so a sparse nested + override (e.g. ``{osl_distribution: {max: 512}}``) preserves siblings. + """ + out = dict(base) + for k, v in override.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +# ModelParams fields that drive the single global tokenizer / MetricsAggregator +# (launched once from top-level model_params), so a per-dataset override would +# desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected +# as generation_config_override keys — they are per-run/identity, not per-dataset. +_METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"}) + + +class AgenticInferenceConfig(BaseModel): + """Agentic inference conversation configuration. + + Configuration for benchmarking conversational AI workloads with turn sequencing. + Enables testing agentic inference conversations where each turn depends on previous responses. + Presence of this block in the dataset config enables agentic inference mode. + + Attributes: + turn_timeout_s: Deadline between issuing a turn and receiving its + response. A timeout aborts that turn and all remaining client + turns of the same conversation because subsequent turns depend + on the timed-out response. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + turn_timeout_s: float = Field( + default=86400.0, + gt=0, + description=( + "Per-turn timeout in seconds. A timeout aborts that turn and all " + "remaining turns in the same conversation." + ), + ) + enable_salt: bool = Field( + False, + description=( + "Add deterministic salt markers before and after the system prompt " + "to prevent KV cache reuse across trajectories in agentic inference setting." + ), + ) + inject_tool_delay: bool = Field( + False, + description=( + "Pause for a predefined duration between turns. Duration is defined " + "in dataset." + ), + ) + routing_headers: tuple[str, ...] = Field( + default=("X-Session-ID",), + description=( + "HTTP header names populated with the conversation ID on every " + "agentic request." + ), + ) + num_trajectories_to_issue: int | None = Field( + default=None, + gt=0, + description=( + "Number of conversation trajectories to start. Defaults to one pass " + "over the dataset; values above the dataset size repeat trajectories " + "with unique logical conversation ids." + ), + ) + stop_issuing_on_first_user_complete: bool = Field( + False, + description=( + "When performance tracking stops because the first concurrency slot " + "has no next trajectory left to assign, also stop issuing future " + "turns. If false, replay continues outside the performance window " + "for accuracy/log coverage." + ), + ) + + +class AccuracyConfig(BaseModel): + """Accuracy configuration. + + eval_method: Scorer to use (see ScorerMethod enum for options). + ground_truth: Column in the dataset containing ground truth. Defaults to "ground_truth". + extractor: Post-processor to extract answers from model output + (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor). + Optional for scorers that declare REQUIRES_EXTRACTOR = False (e.g. vbench). + num_repeats: Number of times to repeat the dataset for evaluation. Defaults to 1. + extras: Free-form keyword args forwarded to the scorer's ``__init__`` — + used for scorer-specific knobs that don't warrant a top-level field + (e.g. ``vbench_project_path``, ``subprocess_timeout_s`` for VBench). + + Example: + accuracy_config: + eval_method: "pass_at_1" + ground_truth: "answer" + extractor: "boxed_math_extractor" + num_repeats: 5 + extras: + vbench_project_path: "/path/to/accuracy" + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + eval_method: ScorerMethod | None = Field(None, description="Scorer method") + ground_truth: str | None = Field(None, description="Ground truth column name") + extractor: str | None = Field( + None, + description="Answer extractor (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor)", + ) + num_repeats: int = Field( + 1, ge=1, description="Repeat dataset N times for evaluation" + ) + extras: dict[str, Any] | None = Field( + None, + description="Free-form scorer kwargs (e.g. vbench_project_path, subprocess_timeout_s)", + ) + + +class Dataset(BaseModel): + """Dataset configuration. + + Name and type have smart defaults: name is auto-derived from path, + type defaults to PERFORMANCE. + + Accepts CLI strings via BeforeValidator on BenchmarkConfig.datasets: + ``[perf|acc:][,key=value...]`` + """ + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + name: str = Field("", description="Dataset name (auto-derived from path if empty)") + type: DatasetType = Field( + DatasetType.PERFORMANCE, description="Dataset purpose: performance or accuracy" + ) + path: Annotated[ + str | None, cyclopts.Parameter(alias="--dataset", help="Dataset file path") + ] = None + format: str | None = Field(None, description="Dataset format (auto-detected)") + samples: int | None = Field(None, gt=0, description="Number of samples to use") + eval_method: EvalMethod | None = Field( + None, description="Accuracy evaluation method" + ) + parser: dict[str, str] | None = Field( + None, description="Column remapping: {prompt: , system: }" + ) + generate_params: dict[str, Any] | None = Field( + None, description="Dataset-specific parameters passed to the generate() method" + ) + accuracy_config: AccuracyConfig | None = Field( + None, description="Accuracy evaluation settings" + ) + agentic_inference: AgenticInferenceConfig | None = Field( + None, description="Agentic inference conversation configuration" + ) + # Per-dataset generation config is a first-class capability: different + # accuracy datasets legitimately want different generation settings (e.g. + # per-dataset max OSL or top_p, as seen in DS-V4), and dataset-scoping also + # enables per-dataset dynamic OSL distributions. Only generation knobs are + # overridable — per-run/identity fields (`_METRICS_DECOUPLED_OVERRIDE_KEYS`: + # name / streaming / tokenizer_name) drive the single global tokenizer and + # MetricsAggregator, so overriding them per-dataset would desync ISL/OSL/ + # TTFT/TPOT accounting; they are rejected at validation. + # + # TODO(post-mortem): split ModelParams into a per-run ModelIdentity and a + # GenerationConfig, so the override surface is exactly the generation fields + # and identity fields cannot be named here at all. Field/method names use + # "generation_config" to keep that migration mechanical. + # + # Nested dicts (`osl_distribution`, `chat_template_kwargs`) are deep-merged + # so sparse overrides preserve sibling defaults. + generation_config_override: dict[str, Any] | None = Field( + None, + description=( + "Per-dataset overrides for the top-level model_params (sparse — " + "only the fields you want to override). Merged on top of " + "BenchmarkConfig.model_params at dataset-load time. Useful for " + "MLPerf-style runs where accuracy and performance use different " + "output budgets in the same fleet, e.g. " + "generation_config_override: {max_new_tokens: 32768, " + "temperature: 0.0}. NOTE: per-run/identity keys (`name`, " + "`streaming`, `tokenizer_name`) are rejected here — set them on " + "top-level model_params." + ), + ) + + @model_validator(mode="after") + def _auto_derive_name(self) -> Self: + """Derive name from path stem if not explicitly provided.""" + if not self.name and self.path: + object.__setattr__(self, "name", Path(self.path).stem) + return self + + @model_validator(mode="after") + def _validate_generation_config_override(self) -> Self: + """Fail fast on unknown keys and on per-run/identity keys the single + global tokenizer / MetricsAggregator would ignore. Override *values* + are validated at merge time (see ``effective_generation_config``) + because cross-field validation needs the base ``ModelParams`` from + ``BenchmarkConfig``. + """ + if self.generation_config_override: + keys = set(self.generation_config_override) + valid = set(ModelParams.model_fields) + bad = sorted(keys - valid) + if bad: + raise ValueError( + f"Dataset '{self.name}': unknown keys in " + f"generation_config_override: {bad}. " + f"Valid keys: {sorted(valid)}" + ) + decoupled = sorted(keys & _METRICS_DECOUPLED_OVERRIDE_KEYS) + if decoupled: + raise ValueError( + f"Dataset '{self.name}': generation_config_override keys " + f"{decoupled} are not honored per-dataset — the single " + "global tokenizer / metrics aggregator is launched from " + "top-level model_params, so a per-dataset value would " + "desync ISL/OSL/TTFT/TPOT accounting. Set them on " + "top-level model_params instead." + ) + return self + + def effective_generation_config(self, base: ModelParams) -> ModelParams: + """Return base merged with this dataset's generation-config overrides. + + Nested dicts are deep-merged so a sparse nested override preserves + sibling defaults (e.g. ``{osl_distribution: {max: 512}}`` keeps the + base ``type/mean/std/min``). The merged dict is re-validated through + ``ModelParams.model_validate`` so type-invalid scalar overrides (e.g. + ``temperature: 'hot'``) are rejected. Note that this only catches + scalar invalidity — a sparse nested override whose merged result + passes default-validation will not raise (callers that need stricter + nested validation should set ``base`` to an explicit instance). + """ + if not self.generation_config_override: + return base + merged = _deep_merge(base.model_dump(), self.generation_config_override) + return ModelParams.model_validate(merged) diff --git a/src/inference_endpoint/config/enums.py b/src/inference_endpoint/config/enums.py new file mode 100644 index 000000000..e48c70e85 --- /dev/null +++ b/src/inference_endpoint/config/enums.py @@ -0,0 +1,131 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Configuration enums. + +Split criterion: one module per config domain; enums shared across the config +models live here so every sibling module can import them without cycles. +``config/schema.py`` re-exports the public surface. +""" + +from __future__ import annotations + +from enum import Enum + + +class LoadPatternType(str, Enum): + """Load pattern types.""" + + MAX_THROUGHPUT = "max_throughput" # Offline: all queries at t=0 + POISSON = "poisson" # Online: fixed QPS with Poisson distribution + CONCURRENCY = "concurrency" # Online: fixed concurrent requests + AGENTIC_INFERENCE = ( + "agentic_inference" # Agentic inference conversations with turn sequencing + ) + BURST = "burst" # Burst pattern (TODO) + STEP = "step" # Step pattern (TODO) + + +class OSLDistributionType(str, Enum): + """Output Sequence Length distribution types.""" + + ORIGINAL = "original" # Use original distribution from dataset (default) + FIXED = "fixed" # Fixed length for all outputs + UNIFORM = "uniform" # Uniform distribution between min and max + NORMAL = "normal" # Normal/Gaussian distribution + + +class DatasetType(str, Enum): + """Dataset purpose type.""" + + PERFORMANCE = "performance" + ACCURACY = "accuracy" + + +class EvalMethod(str, Enum): + """Evaluation methods for accuracy testing.""" + + EXACT_MATCH = "exact_match" + CONTAINS = "contains" + JUDGE = "judge" + + +class ScorerMethod(str, Enum): + """Registered scorer methods for accuracy evaluation.""" + + PASS_AT_1 = "pass_at_1" + STRING_MATCH = "string_match" + ROUGE = "rouge" + CODE_BENCH = "code_bench_scorer" + SHOPIFY_CATEGORY_F1 = "shopify_category_f1" + AGENTIC_INFERENCE_INLINE = "agentic_inference_inline" + VBENCH = "vbench" + BFCL_V4 = "bfcl_v4" + LEGACY_MLPERF_DEEPSEEK_R1 = "legacy_mlperf_deepseek_r1" + SWE_BENCH = "swe_bench_scorer" + + +class TestMode(str, Enum): + """Test mode controlling performance issuance and response collection. + + - PERF: Run performance and ordinary configured scoring without in-process + collection; skip scorers that own an external evaluation run + - ACC: Skip performance and collect responses for configured scoring + - BOTH: Run performance and configured scoring with response collection + """ + + PERF = "perf" + ACC = "acc" + BOTH = "both" + + +class StreamingMode(str, Enum): + """Streaming mode for response handling. + + - AUTO: Automatically enable for online mode, disable for offline mode + - ON: Force streaming enabled (for TTFT metrics) + - OFF: Force streaming disabled + """ + + AUTO = "auto" + ON = "on" + OFF = "off" + + +class TestType(str, Enum): + """Test type for both config classification and execution mode. + + - OFFLINE: Max throughput benchmark (all queries at t=0) + - ONLINE: Sustained QPS benchmark (Poisson or concurrency-based) + - EVAL: Accuracy evaluation + - SUBMISSION: Official submission (may include both perf and accuracy) + """ + + OFFLINE = "offline" + ONLINE = "online" + EVAL = "eval" + SUBMISSION = "submission" + + +class ProfilerEngine(str, Enum): + """Inference engine whose profiling protocol the client should drive. + + Selects the HTTP path layout used to derive start/stop URLs from + ``endpoint_config.endpoints``. Each value corresponds to one server-side + profiling protocol; add a new variant + ``_PROFILE_PATHS`` row to support + another engine. + """ + + VLLM = "vllm" diff --git a/src/inference_endpoint/config/model_params.py b/src/inference_endpoint/config/model_params.py new file mode 100644 index 000000000..a823039d7 --- /dev/null +++ b/src/inference_endpoint/config/model_params.py @@ -0,0 +1,165 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Model generation parameters and submission reference. + +Split criterion: one module per config domain; the model/generation-parameter +models and the submission reference live here. ``config/schema.py`` re-exports +the public surface. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Self + +import cyclopts +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from .enums import OSLDistributionType, StreamingMode +from .ruleset_base import BenchmarkSuiteRuleset + + +def _non_default_completion_controls(mp: ModelParams) -> list[str]: + """Completion-only ModelParams controls set to a non-default value. + + ``min_new_tokens``/``skip_special_tokens`` are only honored by the + ``openai_completions`` adapter; ``BenchmarkConfig`` rejects them for other + ``api_type``s. Shared by the top-level and per-dataset-override checks so + both config surfaces validate identically. + """ + checks = { + "min_new_tokens": mp.min_new_tokens != 1, + "skip_special_tokens": not mp.skip_special_tokens, + } + return [name for name, non_default in checks.items() if non_default] + + +class OSLDistribution(BaseModel): + """Output Sequence Length distribution configuration. + + Distribution types: + - ORIGINAL: Use the natural distribution from the dataset (default) + - FIXED: All outputs have the same length (uses mean value) + - UNIFORM: Uniformly distributed between min and max + - NORMAL: Normal/Gaussian distribution with mean and std + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: OSLDistributionType = Field( + OSLDistributionType.ORIGINAL, description="Distribution type" + ) + mean: int | None = Field(None, description="Mean length (FIXED/NORMAL)") + std: int | None = Field(None, description="Std deviation (NORMAL)") + min: Annotated[ + int, + cyclopts.Parameter(alias="--min-output-tokens", help="Minimum output length"), + ] = 1 + max: int = Field(2048, description="Maximum output length") + + +class ModelParams(BaseModel): + """Model generation parameters.""" + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + name: Annotated[ + str, + cyclopts.Parameter(alias="--model", help="Model name", required=True), + ] = "" + temperature: float | None = Field(None, description="Sampling temperature") + seed: Annotated[ + int | None, + cyclopts.Parameter( + alias="--seed", help="Random seed for reproducible sampling" + ), + ] = Field(None, description="Random seed for reproducible sampling") + top_k: int | None = Field(None, description="Top-K sampling") + top_p: float | None = Field(None, description="Top-P (nucleus) sampling") + repetition_penalty: float | None = Field(None, description="Repetition penalty") + presence_penalty: float | None = Field(None, description="Presence penalty") + frequency_penalty: float | None = Field(None, description="Frequency penalty") + chat_template_kwargs: dict[str, Any] | None = Field( + None, + description="Per-request chat-template kwargs forwarded to compatible servers.", + ) + max_new_tokens: Annotated[ + int, cyclopts.Parameter(alias="--max-output-tokens", help="Max output tokens") + ] = 1024 + min_new_tokens: int = Field( + 1, + ge=0, + description="Minimum output tokens for OpenAI text-completions servers", + ) + skip_special_tokens: bool = Field( + True, + description=( + "Whether OpenAI text-completions servers omit special tokens from decoded output" + ), + ) + osl_distribution: OSLDistribution | None = Field( + None, description="Output sequence length distribution" + ) + streaming: Annotated[ + StreamingMode, + cyclopts.Parameter(alias="--streaming", help="Streaming mode: auto/on/off"), + ] = StreamingMode.AUTO + tokenizer_name: Annotated[ + str | None, + cyclopts.Parameter( + alias="--tokenizer", + help="HF repo ID or local path for the tokenizer. Overrides model name for client-side token metrics (ISL/OSL/TPOT).", + ), + ] = None + + @model_validator(mode="after") + def _validate_generation_lengths(self) -> Self: + if self.min_new_tokens > self.max_new_tokens: + raise ValueError( + "min_new_tokens must be less than or equal to max_new_tokens" + ) + return self + + +class SubmissionReference(BaseModel): + """Reference configuration for official benchmark submissions. + + Links a submission to a specific model and ruleset (competition rules). + The ruleset defines constraints like min duration, sample counts, and + performance targets that must be met for a valid submission. + + Example: + submission_ref: + model: "llama-2-70b" + ruleset: "mlperf-inference-v5.1" + """ + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + model: str # Model identifier (e.g., "llama-2-70b") + ruleset: str # Ruleset name/version (e.g., "mlperf-inference-v5.1") + + def get_ruleset_instance(self) -> BenchmarkSuiteRuleset: + """Get the actual ruleset instance from registry. + + Returns: + BenchmarkSuiteRuleset instance + + Raises: + KeyError: If ruleset not found in registry + """ + from .ruleset_registry import get_ruleset + + return get_ruleset(self.ruleset) diff --git a/src/inference_endpoint/config/rulesets/mlcommons/rules.py b/src/inference_endpoint/config/rulesets/mlcommons/rules.py index 2625d514c..1a12a40f7 100644 --- a/src/inference_endpoint/config/rulesets/mlcommons/rules.py +++ b/src/inference_endpoint/config/rulesets/mlcommons/rules.py @@ -27,7 +27,6 @@ from .... import metrics from ...ruleset_base import BenchmarkSuiteRuleset from ...runtime_settings import RuntimeSettings -from ...schema import SystemDefaults from ...user_config import UserConfig from . import models @@ -214,7 +213,7 @@ def apply_user_config( return _RuntimeSettings( metric_target=metric_target if metric_target is not None - else SystemDefaults.DEFAULT_METRIC, + else metrics.Throughput(0.0), reported_metrics=reported_metrics, min_duration_ms=min_duration_ms, max_duration_ms=max_duration_ms, diff --git a/src/inference_endpoint/config/runtime_settings.py b/src/inference_endpoint/config/runtime_settings.py index 6573ef82a..3bd6e15bc 100644 --- a/src/inference_endpoint/config/runtime_settings.py +++ b/src/inference_endpoint/config/runtime_settings.py @@ -94,9 +94,6 @@ class RuntimeSettings: reported_metrics: list[metrics.Metric] """List of metrics to collect and report""" - min_duration_ms: int - """Minimum benchmark duration in milliseconds""" - max_duration_ms: int | None """Maximum benchmark duration in milliseconds (timeout). None means no wall-clock limit.""" @@ -118,6 +115,11 @@ class RuntimeSettings: load_pattern: LoadPattern | None """Load pattern configuration""" + min_duration_ms: int | None = field(default=None, kw_only=True) + """Minimum performance-phase duration in ms (None/0 = no duration target: + issue the dataset once). Only rulesets set this; the config surface has no + duration-derived sample count.""" + sample_order: SampleOrderSpec = field(default_factory=SampleOrderSpec, kw_only=True) """Sample-ordering strategy (default: without-replacement).""" @@ -188,10 +190,8 @@ def _from_config_default( kwargs = { "metric_target": metrics.Throughput(effective_qps), "reported_metrics": [metrics.Throughput(effective_qps)], - "min_duration_ms": runtime_cfg.min_duration_ms, - "max_duration_ms": None - if runtime_cfg.max_duration_ms == 0 - else runtime_cfg.max_duration_ms, + "min_duration_ms": None, + "max_duration_ms": runtime_cfg.max_duration_ms, "n_samples_from_dataset": dataloader_num_samples, "n_samples_to_issue": runtime_cfg.n_samples_to_issue, # From config (CLI --num-samples or YAML) "min_sample_count": 1, @@ -213,7 +213,7 @@ def total_samples_to_issue( Priority: 1. If `n_samples_to_issue` is set, return it (explicit override) - 2. If min_duration_ms=0, return all dataset samples (new CLI default) + 2. If no duration target is set, return all dataset samples 3. Otherwise, calculate from metric target * duration Args: @@ -251,11 +251,12 @@ def total_samples_to_issue( ) return self.n_samples_from_dataset - # If min_duration is 0, use all dataset samples (new CLI default behavior) - if self.min_duration_ms == 0: + # No duration target (None from config, 0 from programmatic callers): + # issue the dataset once. + if not self.min_duration_ms: result = max(self.min_sample_count, self.n_samples_from_dataset) logger.debug( - f"Sample count: {result} (using all dataset samples, duration=0)" + f"Sample count: {result} (using all dataset samples, no duration target)" ) return result diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index e9a69e5d2..c809e0f8b 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -15,18 +15,23 @@ """Configuration schema — single source of truth for YAML and CLI. -All Pydantic models here define both the YAML config structure and the CLI interface. -cyclopts auto-generates CLI flags from fields. Use cyclopts.Parameter(alias=...) -on Annotated fields to declare shorthand aliases alongside dotted paths. +All Pydantic models define both the YAML config structure and the CLI +interface. cyclopts auto-generates CLI flags from fields. Use +cyclopts.Parameter(alias=...) on Annotated fields to declare shorthand +aliases alongside dotted paths. + +Split criterion: one module per config domain (enums / audit / model_params / +datasets / settings / timeouts); this module owns only the root aggregate +(``BenchmarkConfig`` and its cross-field validation) plus the explicit +re-export hub, so every existing ``config.schema`` import site keeps working. """ from __future__ import annotations import logging from collections import Counter -from enum import Enum from pathlib import Path -from typing import Annotated, Any, ClassVar, Literal, Self, Union +from typing import Annotated, Any, Literal, Self, Union from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit import cyclopts @@ -36,972 +41,85 @@ ConfigDict, Discriminator, Field, - SerializerFunctionWrapHandler, Tag, TypeAdapter, field_validator, - model_serializer, model_validator, ) -from .. import metrics from ..core.types import APIType -from ..endpoint_client.config import HTTPClientConfig from ..exceptions import CLIError from ..utils import WithUpdatesMixin -from .ruleset_base import BenchmarkSuiteRuleset +from .audit import AuditConfig, AuditTestId, OutputCachingTestConfig +from .datasets import AccuracyConfig, AgenticInferenceConfig, Dataset +from .enums import ( + DatasetType, + EvalMethod, + LoadPatternType, + OSLDistributionType, + ProfilerEngine, + ScorerMethod, + StreamingMode, + TestMode, + TestType, +) +from .model_params import ( + ModelParams, + OSLDistribution, + SubmissionReference, + _non_default_completion_controls, +) +from .settings import ( + EarlyStoppingConfig, + LoadPattern, + OfflineSettings, + OnlineSettings, + ProfilingConfig, + RuntimeConfig, + Settings, + WarmupConfig, +) +from .timeouts import Timeouts from .utils import parse_dataset_string, resolve_env_vars -logger = logging.getLogger(__name__) - - -class SystemDefaults(BaseModel): - DEFAULT_TIMEOUT: ClassVar[float] = 300.0 - DEFAULT_METRIC: ClassVar[metrics.Metric] = metrics.Throughput(0.0) - - -def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: - """Recursively merge ``override`` into ``base`` and return the result. - - For overlapping keys whose values are both dicts, recurse; otherwise the - override value wins. Mutates a *copy* — callers can safely pass model_dump() - output. Used by ``Dataset.effective_generation_config`` so a sparse nested - override (e.g. ``{osl_distribution: {max: 512}}``) preserves siblings. - """ - out = dict(base) - for k, v in override.items(): - if isinstance(v, dict) and isinstance(out.get(k), dict): - out[k] = _deep_merge(out[k], v) - else: - out[k] = v - return out - - -# ModelParams fields that drive the single global tokenizer / MetricsAggregator -# (launched once from top-level model_params), so a per-dataset override would -# desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected -# as generation_config_override keys — they are per-run/identity, not per-dataset. -_METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"}) - - -def _non_default_completion_controls(mp: ModelParams) -> list[str]: - """Completion-only ModelParams controls set to a non-default value. - - ``min_new_tokens``/``skip_special_tokens`` are only honored by the - ``openai_completions`` adapter; ``BenchmarkConfig`` rejects them for other - ``api_type``s. Shared by the top-level and per-dataset-override checks so - both config surfaces validate identically. - """ - checks = { - "min_new_tokens": mp.min_new_tokens != 1, - "skip_special_tokens": not mp.skip_special_tokens, - } - return [name for name, non_default in checks.items() if non_default] - - -class LoadPatternType(str, Enum): - """Load pattern types.""" - - MAX_THROUGHPUT = "max_throughput" # Offline: all queries at t=0 - POISSON = "poisson" # Online: fixed QPS with Poisson distribution - CONCURRENCY = "concurrency" # Online: fixed concurrent requests - AGENTIC_INFERENCE = ( - "agentic_inference" # Agentic inference conversations with turn sequencing - ) - BURST = "burst" # Burst pattern (TODO) - STEP = "step" # Step pattern (TODO) - - -class OSLDistributionType(str, Enum): - """Output Sequence Length distribution types.""" - - ORIGINAL = "original" # Use original distribution from dataset (default) - FIXED = "fixed" # Fixed length for all outputs - UNIFORM = "uniform" # Uniform distribution between min and max - NORMAL = "normal" # Normal/Gaussian distribution - - -class DatasetType(str, Enum): - """Dataset purpose type.""" - - PERFORMANCE = "performance" - ACCURACY = "accuracy" - - -class EvalMethod(str, Enum): - """Evaluation methods for accuracy testing.""" - - EXACT_MATCH = "exact_match" - CONTAINS = "contains" - JUDGE = "judge" - - -class ScorerMethod(str, Enum): - """Registered scorer methods for accuracy evaluation.""" - - PASS_AT_1 = "pass_at_1" - STRING_MATCH = "string_match" - ROUGE = "rouge" - CODE_BENCH = "code_bench_scorer" - SHOPIFY_CATEGORY_F1 = "shopify_category_f1" - AGENTIC_INFERENCE_INLINE = "agentic_inference_inline" - VBENCH = "vbench" - BFCL_V4 = "bfcl_v4" - LEGACY_MLPERF_DEEPSEEK_R1 = "legacy_mlperf_deepseek_r1" - SWE_BENCH = "swe_bench_scorer" - - -class AuditTestId(str, Enum): - """Registered compliance audit test identifiers.""" - - # Output-caching audit — MLPerf TEST04 (duplicate-query caching detection). - OUTPUT_CACHING_TEST = "output_caching_test" - - -class OutputCachingTestConfig(BaseModel): - """Configuration for the output-caching audit (MLPerf TEST04). - - The output-caching test runs two back-to-back phases — a reference run of - distinct samples and an audit run that repeats one fixed sample — then - checks that the audit QPS does not exceed the reference QPS by more than - ``threshold``. A large speedup indicates the SUT is caching responses. - - samples: reference-phase query count (required — an explicit count keeps - the per-phase completion check meaningful; a duration-driven phase has - no independent target to validate completion against) - audit_samples: audit-phase query count (None → equals samples) - sample_index: which dataset row is repeated (MLCommons performance_issue_same_index) - threshold: tolerance shared by both pass checks — each phase must complete - ≥ requested * (1 - threshold), and audit_qps must stay < ref_qps * (1 + threshold) - """ - - model_config = ConfigDict(frozen=True, extra="forbid") - - test: Literal[AuditTestId.OUTPUT_CACHING_TEST] - only: bool = Field( - False, - description="Run only the audit — skip the main benchmark (upstream-style standalone TEST04)", - ) - samples: int = Field(..., ge=1, description="Reference phase query count") - audit_samples: int | None = Field( - None, ge=1, description="Audit phase query count (default: equals samples)" - ) - sample_index: int = Field( - 0, ge=0, description="Dataset row index repeated in the audit phase" - ) - threshold: float = Field( - 0.10, - gt=0, - lt=1, - description=( - "Tolerance for both checks: each phase must complete " - "≥ requested * (1 - threshold), and audit_qps must stay " - "< ref_qps * (1 + threshold)" - ), - ) - - -# Single member today; becomes -# Annotated[OutputCachingTestConfig | ..., Field(discriminator="test")] -# when additional audit tests are added. -AuditConfig = OutputCachingTestConfig - - -class TestMode(str, Enum): - """Test mode controlling performance issuance and response collection. - - - PERF: Run performance and ordinary configured scoring without in-process - collection; skip scorers that own an external evaluation run - - ACC: Skip performance and collect responses for configured scoring - - BOTH: Run performance and configured scoring with response collection - """ - - PERF = "perf" - ACC = "acc" - BOTH = "both" - - -class StreamingMode(str, Enum): - """Streaming mode for response handling. - - - AUTO: Automatically enable for online mode, disable for offline mode - - ON: Force streaming enabled (for TTFT metrics) - - OFF: Force streaming disabled - """ - - AUTO = "auto" - ON = "on" - OFF = "off" - - -class TestType(str, Enum): - """Test type for both config classification and execution mode. - - - OFFLINE: Max throughput benchmark (all queries at t=0) - - ONLINE: Sustained QPS benchmark (Poisson or concurrency-based) - - EVAL: Accuracy evaluation - - SUBMISSION: Official submission (may include both perf and accuracy) - """ - - OFFLINE = "offline" - ONLINE = "online" - EVAL = "eval" - SUBMISSION = "submission" - - -# Mapping from template type strings to TestType enums -# Single source of truth for template type conversion -TEMPLATE_TYPE_MAP = { - "offline": TestType.OFFLINE, - "online": TestType.ONLINE, - "eval": TestType.EVAL, - "submission": TestType.SUBMISSION, -} - - -class OSLDistribution(BaseModel): - """Output Sequence Length distribution configuration. - - Distribution types: - - ORIGINAL: Use the natural distribution from the dataset (default) - - FIXED: All outputs have the same length (uses mean value) - - UNIFORM: Uniformly distributed between min and max - - NORMAL: Normal/Gaussian distribution with mean and std - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - type: OSLDistributionType = Field( - OSLDistributionType.ORIGINAL, description="Distribution type" - ) - mean: int | None = Field(None, description="Mean length (FIXED/NORMAL)") - std: int | None = Field(None, description="Std deviation (NORMAL)") - min: Annotated[ - int, - cyclopts.Parameter(alias="--min-output-tokens", help="Minimum output length"), - ] = 1 - max: int = Field(2048, description="Maximum output length") - - -class ModelParams(BaseModel): - """Model generation parameters.""" - - model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) - - name: Annotated[ - str, - cyclopts.Parameter(alias="--model", help="Model name", required=True), - ] = "" - temperature: float | None = Field(None, description="Sampling temperature") - seed: Annotated[ - int | None, - cyclopts.Parameter( - alias="--seed", help="Random seed for reproducible sampling" - ), - ] = Field(None, description="Random seed for reproducible sampling") - top_k: int | None = Field(None, description="Top-K sampling") - top_p: float | None = Field(None, description="Top-P (nucleus) sampling") - repetition_penalty: float | None = Field(None, description="Repetition penalty") - presence_penalty: float | None = Field(None, description="Presence penalty") - frequency_penalty: float | None = Field(None, description="Frequency penalty") - chat_template_kwargs: dict[str, Any] | None = Field( - None, - description="Per-request chat-template kwargs forwarded to compatible servers.", - ) - max_new_tokens: Annotated[ - int, cyclopts.Parameter(alias="--max-output-tokens", help="Max output tokens") - ] = 1024 - min_new_tokens: int = Field( - 1, - ge=0, - description="Minimum output tokens for OpenAI text-completions servers", - ) - skip_special_tokens: bool = Field( - True, - description=( - "Whether OpenAI text-completions servers omit special tokens from decoded output" - ), - ) - osl_distribution: OSLDistribution | None = Field( - None, description="Output sequence length distribution" - ) - streaming: Annotated[ - StreamingMode, - cyclopts.Parameter(alias="--streaming", help="Streaming mode: auto/on/off"), - ] = StreamingMode.AUTO - tokenizer_name: Annotated[ - str | None, - cyclopts.Parameter( - alias="--tokenizer", - help="HF repo ID or local path for the tokenizer. Overrides model name for client-side token metrics (ISL/OSL/TPOT).", - ), - ] = None - - @model_validator(mode="after") - def _validate_generation_lengths(self) -> Self: - if self.min_new_tokens > self.max_new_tokens: - raise ValueError( - "min_new_tokens must be less than or equal to max_new_tokens" - ) - return self - - -class SubmissionReference(BaseModel): - """Reference configuration for official benchmark submissions. - - Links a submission to a specific model and ruleset (competition rules). - The ruleset defines constraints like min duration, sample counts, and - performance targets that must be met for a valid submission. - - Example: - submission_ref: - model: "llama-2-70b" - ruleset: "mlperf-inference-v5.1" - """ - - model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) - - model: str # Model identifier (e.g., "llama-2-70b") - ruleset: str # Ruleset name/version (e.g., "mlperf-inference-v5.1") - - def get_ruleset_instance(self) -> BenchmarkSuiteRuleset: - """Get the actual ruleset instance from registry. - - Returns: - BenchmarkSuiteRuleset instance - - Raises: - KeyError: If ruleset not found in registry - """ - from .ruleset_registry import get_ruleset - - return get_ruleset(self.ruleset) - - -class AgenticInferenceConfig(BaseModel): - """Agentic inference conversation configuration. - - Configuration for benchmarking conversational AI workloads with turn sequencing. - Enables testing agentic inference conversations where each turn depends on previous responses. - Presence of this block in the dataset config enables agentic inference mode. - - Attributes: - turn_timeout_s: Deadline between issuing a turn and receiving its - response. A timeout aborts that turn and all remaining client - turns of the same conversation because subsequent turns depend - on the timed-out response. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - turn_timeout_s: float = Field( - default=86400.0, - gt=0, - description=( - "Per-turn timeout in seconds. A timeout aborts that turn and all " - "remaining turns in the same conversation." - ), - ) - enable_salt: bool = Field( - False, - description=( - "Add deterministic salt markers before and after the system prompt " - "to prevent KV cache reuse across trajectories in agentic inference setting." - ), - ) - inject_tool_delay: bool = Field( - False, - description=( - "Pause for a predefined duration between turns. Duration is defined " - "in dataset." - ), - ) - routing_headers: tuple[str, ...] = Field( - default=("X-Session-ID",), - description=( - "HTTP header names populated with the conversation ID on every " - "agentic request." - ), - ) - num_trajectories_to_issue: int | None = Field( - default=None, - gt=0, - description=( - "Number of conversation trajectories to start. Defaults to one pass " - "over the dataset; values above the dataset size repeat trajectories " - "with unique logical conversation ids." - ), - ) - stop_issuing_on_first_user_complete: bool = Field( - False, - description=( - "When performance tracking stops because the first concurrency slot " - "has no next trajectory left to assign, also stop issuing future " - "turns. If false, replay continues outside the performance window " - "for accuracy/log coverage." - ), - ) - - -class Dataset(BaseModel): - """Dataset configuration. - - Name and type have smart defaults: name is auto-derived from path, - type defaults to PERFORMANCE. - - Accepts CLI strings via BeforeValidator on BenchmarkConfig.datasets: - ``[perf|acc:][,key=value...]`` - """ - - model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) - - name: str = Field("", description="Dataset name (auto-derived from path if empty)") - type: DatasetType = Field( - DatasetType.PERFORMANCE, description="Dataset purpose: performance or accuracy" - ) - path: Annotated[ - str | None, cyclopts.Parameter(alias="--dataset", help="Dataset file path") - ] = None - format: str | None = Field(None, description="Dataset format (auto-detected)") - samples: int | None = Field(None, gt=0, description="Number of samples to use") - eval_method: EvalMethod | None = Field( - None, description="Accuracy evaluation method" - ) - parser: dict[str, str] | None = Field( - None, description="Column remapping: {prompt: , system: }" - ) - generate_params: dict[str, Any] | None = Field( - None, description="Dataset-specific parameters passed to the generate() method" - ) - accuracy_config: AccuracyConfig | None = Field( - None, description="Accuracy evaluation settings" - ) - agentic_inference: AgenticInferenceConfig | None = Field( - None, description="Agentic inference conversation configuration" - ) - # Per-dataset generation config is a first-class capability: different - # accuracy datasets legitimately want different generation settings (e.g. - # per-dataset max OSL or top_p, as seen in DS-V4), and dataset-scoping also - # enables per-dataset dynamic OSL distributions. Only generation knobs are - # overridable — per-run/identity fields (`_METRICS_DECOUPLED_OVERRIDE_KEYS`: - # name / streaming / tokenizer_name) drive the single global tokenizer and - # MetricsAggregator, so overriding them per-dataset would desync ISL/OSL/ - # TTFT/TPOT accounting; they are rejected at validation. - # - # TODO(post-mortem): split ModelParams into a per-run ModelIdentity and a - # GenerationConfig, so the override surface is exactly the generation fields - # and identity fields cannot be named here at all. Field/method names use - # "generation_config" to keep that migration mechanical. - # - # Nested dicts (`osl_distribution`, `chat_template_kwargs`) are deep-merged - # so sparse overrides preserve sibling defaults. - generation_config_override: dict[str, Any] | None = Field( - None, - description=( - "Per-dataset overrides for the top-level model_params (sparse — " - "only the fields you want to override). Merged on top of " - "BenchmarkConfig.model_params at dataset-load time. Useful for " - "MLPerf-style runs where accuracy and performance use different " - "output budgets in the same fleet, e.g. " - "generation_config_override: {max_new_tokens: 32768, " - "temperature: 0.0}. NOTE: per-run/identity keys (`name`, " - "`streaming`, `tokenizer_name`) are rejected here — set them on " - "top-level model_params." - ), - ) - - @model_validator(mode="after") - def _auto_derive_name(self) -> Self: - """Derive name from path stem if not explicitly provided.""" - if not self.name and self.path: - object.__setattr__(self, "name", Path(self.path).stem) - return self - - @model_validator(mode="after") - def _validate_generation_config_override(self) -> Self: - """Fail fast on unknown keys and on per-run/identity keys the single - global tokenizer / MetricsAggregator would ignore. Override *values* - are validated at merge time (see ``effective_generation_config``) - because cross-field validation needs the base ``ModelParams`` from - ``BenchmarkConfig``. - """ - if self.generation_config_override: - keys = set(self.generation_config_override) - valid = set(ModelParams.model_fields) - bad = sorted(keys - valid) - if bad: - raise ValueError( - f"Dataset '{self.name}': unknown keys in " - f"generation_config_override: {bad}. " - f"Valid keys: {sorted(valid)}" - ) - decoupled = sorted(keys & _METRICS_DECOUPLED_OVERRIDE_KEYS) - if decoupled: - raise ValueError( - f"Dataset '{self.name}': generation_config_override keys " - f"{decoupled} are not honored per-dataset — the single " - "global tokenizer / metrics aggregator is launched from " - "top-level model_params, so a per-dataset value would " - "desync ISL/OSL/TTFT/TPOT accounting. Set them on " - "top-level model_params instead." - ) - return self - - def effective_generation_config(self, base: ModelParams) -> ModelParams: - """Return base merged with this dataset's generation-config overrides. - - Nested dicts are deep-merged so a sparse nested override preserves - sibling defaults (e.g. ``{osl_distribution: {max: 512}}`` keeps the - base ``type/mean/std/min``). The merged dict is re-validated through - ``ModelParams.model_validate`` so type-invalid scalar overrides (e.g. - ``temperature: 'hot'``) are rejected. Note that this only catches - scalar invalidity — a sparse nested override whose merged result - passes default-validation will not raise (callers that need stricter - nested validation should set ``base`` to an explicit instance). - """ - if not self.generation_config_override: - return base - merged = _deep_merge(base.model_dump(), self.generation_config_override) - return ModelParams.model_validate(merged) - - -class AccuracyConfig(BaseModel): - """Accuracy configuration. - - eval_method: Scorer to use (see ScorerMethod enum for options). - ground_truth: Column in the dataset containing ground truth. Defaults to "ground_truth". - extractor: Post-processor to extract answers from model output - (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor). - Optional for scorers that declare REQUIRES_EXTRACTOR = False (e.g. vbench). - num_repeats: Number of times to repeat the dataset for evaluation. Defaults to 1. - extras: Free-form keyword args forwarded to the scorer's ``__init__`` — - used for scorer-specific knobs that don't warrant a top-level field - (e.g. ``vbench_project_path``, ``subprocess_timeout_s`` for VBench). - - Example: - accuracy_config: - eval_method: "pass_at_1" - ground_truth: "answer" - extractor: "boxed_math_extractor" - num_repeats: 5 - extras: - vbench_project_path: "/path/to/accuracy" - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - eval_method: ScorerMethod | None = Field(None, description="Scorer method") - ground_truth: str | None = Field(None, description="Ground truth column name") - extractor: str | None = Field( - None, - description="Answer extractor (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor)", - ) - num_repeats: int = Field( - 1, ge=1, description="Repeat dataset N times for evaluation" - ) - extras: dict[str, Any] | None = Field( - None, - description="Free-form scorer kwargs (e.g. vbench_project_path, subprocess_timeout_s)", - ) - - -class RuntimeConfig(BaseModel): - """Runtime configuration. - - Sample count priority (in RuntimeSettings.total_samples_to_issue()): - 1. n_samples_to_issue (if specified) — explicit override - 2. Calculated from QPS * duration — duration-based (default: 600000ms) - 3. All dataset samples — fallback when duration is 0 - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - min_duration_ms: Annotated[ - int, - cyclopts.Parameter( - alias="--duration", help="Min duration (ms, or with suffix: 600s, 10m)" - ), - ] = Field(600000, ge=0) - max_duration_ms: int = Field( - 0, - ge=0, - description="Maximum test duration in ms (0 for no limit)", - ) - - @field_validator("min_duration_ms", "max_duration_ms", mode="before") - @classmethod - def _parse_duration_suffix(cls, v: object) -> object: - """Accept duration with unit suffix: 600s, 10m, 600000ms, or plain int (ms).""" - if isinstance(v, str): - v = v.strip() - if v.endswith("ms"): - return int(v[:-2]) - if v.endswith("m"): - return int(float(v[:-1]) * 60_000) - if v.endswith("s"): - return int(float(v[:-1]) * 1000) - return v - - n_samples_to_issue: Annotated[ - int | None, - cyclopts.Parameter(alias="--num-samples", help="Sample count override"), - ] = Field(None, gt=0) - scheduler_random_seed: int = Field(42, description="Scheduler RNG seed") - dataloader_random_seed: int = Field(42, description="Dataloader RNG seed") - - @model_validator(mode="after") - def _validate_durations(self) -> Self: - if self.max_duration_ms != 0 and self.max_duration_ms < self.min_duration_ms: - raise ValueError( - f"max_duration_ms ({self.max_duration_ms}) must be >= " - f"min_duration_ms ({self.min_duration_ms})" - ) - return self - - -@cyclopts.Parameter(name="*") -class LoadPattern(BaseModel): - """Load pattern configuration. - - Different patterns use target_qps differently: - - max_throughput: target_qps used for calculating total queries (offline, optional with default) - - poisson: target_qps sets scheduler rate (online, required - validated) - - concurrency: issue at fixed target_concurrency (online, required - validated) - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - type: Annotated[ - LoadPatternType, - cyclopts.Parameter(name="--load-pattern", help="Load pattern type"), - ] = LoadPatternType.MAX_THROUGHPUT - target_qps: Annotated[ - float | None, cyclopts.Parameter(alias="--target-qps", help="Target QPS") - ] = Field(None, gt=0) - target_concurrency: Annotated[ - int | None, - cyclopts.Parameter(alias="--concurrency", help="Concurrent requests"), - ] = Field(None, gt=0) - - # TODO(vir): remove once the formal tail-cutting mechanism lands. - use_legacy_loadgen_qps_metrics: Annotated[ - bool, - cyclopts.Parameter( - negative="--no-use-legacy-loadgen-qps-metrics", - help=( - "Only applies to the poisson load pattern. Report QPS/TPS using " - "the legacy MLPerf LoadGen Server 'completed' definition — (completed-1)/T " - "and tokens/T, T = first issued request to completion of the " - "last-issued request (see mlcommons/inference loadgen/results.cc). " - "--no-... uses endpoints-native completed/duration. Ignored for " - "non-poisson patterns." - ), - ), - ] = True - - @model_serializer(mode="wrap") - def _serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: - # use_legacy_loadgen_qps_metrics only applies to poisson; drop it from - # the serialized form (and thus YAML templates) for other patterns. - data = handler(self) - if self.type != LoadPatternType.POISSON: - data.pop("use_legacy_loadgen_qps_metrics", None) - return data - - @model_validator(mode="after") - def _validate_completeness(self) -> Self: - if self.type == LoadPatternType.POISSON and ( - self.target_qps is None or self.target_qps <= 0 - ): - raise ValueError("Poisson requires --target-qps (e.g., --target-qps 100)") - if self.type == LoadPatternType.CONCURRENCY and ( - not self.target_concurrency or self.target_concurrency <= 0 - ): - raise ValueError( - "Concurrency requires --concurrency (e.g., --concurrency 10)" - ) - if self.type == LoadPatternType.AGENTIC_INFERENCE and ( - not self.target_concurrency or self.target_concurrency <= 0 - ): - raise ValueError( - "Agentic inference requires --concurrency (e.g., --concurrency 96)" - ) - return self - - def __str__(self) -> str: - """Human-readable "type (param=value)" form for logging, e.g. - ``concurrency (target_concurrency=7)`` / ``poisson (target_qps=10.0)``. - Patterns without a driving parameter render as just the type name. - """ - if self.type in ( - LoadPatternType.CONCURRENCY, - LoadPatternType.AGENTIC_INFERENCE, - ): - return f"{self.type.value} (target_concurrency={self.target_concurrency})" - if self.type == LoadPatternType.POISSON: - return f"{self.type.value} (target_qps={self.target_qps})" - return self.type.value - - -@cyclopts.Parameter(name="*") -class WarmupConfig(BaseModel): - """Warmup phase configuration. Runs before the performance phase; results are not recorded.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - enabled: Annotated[ - bool, - cyclopts.Parameter( - alias="--warmup", help="Enable warmup phase before performance run" - ), - ] = Field(False, description="Enable warmup phase before performance run") - n_requests: Annotated[ - int | None, - cyclopts.Parameter( - alias="--warmup-requests", - help="Warmup request count (None = full dataset once)", - ), - ] = Field(None, gt=0, description="Warmup request count (None = full dataset once)") - salt: Annotated[ - bool, - cyclopts.Parameter( - alias="--warmup-salt", - help="Prepend a unique random hex salt to each warmup prompt", - ), - ] = Field( - True, description="Prepend a unique random hex salt to each warmup prompt" - ) - drain: Annotated[ - bool, - cyclopts.Parameter( - alias="--warmup-drain", - help="Drain in-flight warmup requests before starting the performance phase", - ), - ] = Field( - False, - description="Drain in-flight warmup requests before starting the performance phase", - ) - warmup_random_seed: Annotated[ - int, - cyclopts.Parameter( - alias="--warmup-seed", - help="RNG seed for warmup scheduling and sample ordering", - ), - ] = Field(42, description="RNG seed for warmup scheduling and sample ordering") - - -class DrainConfig(BaseModel): - """Per-phase in-flight response drain timeout configuration.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - warmup_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--warmup-drain-timeout", - help="Warmup drain timeout in seconds (None = wait indefinitely)", - ), - ] = Field( - 240.0, - gt=0, - description="Warmup drain timeout in seconds (None = wait indefinitely)", - ) - performance_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--performance-drain-timeout", - help="Performance drain timeout in seconds (None = wait indefinitely)", - ), - ] = Field( - 240.0, - gt=0, - description="Performance drain timeout in seconds (None = wait indefinitely)", - ) - accuracy_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--accuracy-drain-timeout", - help="Accuracy drain timeout in seconds (None = wait indefinitely)", - ), - ] = Field( - None, - gt=0, - description="Accuracy drain timeout in seconds (None = wait indefinitely)", - ) - metrics_drain_timeout_s: Annotated[ - float, - cyclopts.Parameter( - alias="--metrics-drain-timeout", - help=( - "Wall-clock budget (seconds) for the metrics aggregator to finish " - "tokenizing buffered samples after the run ends. Set to 0 to wait " - "indefinitely. Increase for very large datasets where the end-of-run " - "tokenize batch is big." - ), - ), - ] = Field( - 0.0, - ge=0, - description=( - "Wall-clock budget (seconds) to finish tokenizing buffered samples " - "after ENDED (default: 0 = unlimited). An incomplete drain is " - "surfaced via n_pending_tasks > 0, never silently dropped." - ), - ) - metrics_tokenizer_workers: Annotated[ - int, - cyclopts.Parameter( - alias="--metrics-tokenizer-workers", - help=( - "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT in " - "the metrics aggregator. 0 defers all tokenization to the " - "end-of-run drain, which always uses the auto-sized sharded pool." - ), - ), - ] = Field( - 4, - ge=0, - description=( - "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT " - "(default: 4; 0 = defer everything to the end-of-run drain)." - ), - ) - - -class ProfilerEngine(str, Enum): - """Inference engine whose profiling protocol the client should drive. - - Selects the HTTP path layout used to derive start/stop URLs from - ``endpoint_config.endpoints``. Each value corresponds to one server-side - profiling protocol; add a new variant + ``_PROFILE_PATHS`` row to support - another engine. - """ - - VLLM = "vllm" - - -@cyclopts.Parameter(name="*") -class ProfilingConfig(BaseModel): - """Client-side trigger for the server's profiler. - - When ``engine`` is set, fires POST ```` at performance-phase - begin and POST ```` at performance-phase end. URLs are derived - using the engine-specific protocol from ``urls`` when set, otherwise - from ``endpoint_config.endpoints``. - Server must be launched with profiling enabled (e.g. vLLM's - ``--profiler-config.profiler=cuda|torch``); the schedule - (``delay_iterations``, ``max_iterations``) is set there, not here. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - engine: Annotated[ - ProfilerEngine | None, - cyclopts.Parameter( - alias="--profile", - help="Profile the named inference engine around the performance phase", - ), - ] = Field( - None, - description="Profile the named inference engine around the performance phase", - ) - urls: Annotated[ - list[str] | None, - cyclopts.Parameter( - alias="--profile-urls", - help="Override URL(s) for profiler triggers; " - "defaults to endpoint_config.endpoints", - negative="", - ), - ] = Field( - None, - description="URL(s) the profiler start/stop triggers are derived from. " - "When None, derived from endpoint_config.endpoints instead. Use when " - "the profiler admin endpoint differs from the inference endpoint.", - ) - - @field_validator("urls", mode="after") - @classmethod - def _validate_url_scheme(cls, v: list[str] | None) -> list[str] | None: - if v is None: - return v - for url in v: - if not url.startswith(("http://", "https://")): - raise ValueError( - f"Profiling endpoint URL must include scheme " - f"(http:// or https://), got: {url!r}" - ) - return v - - -class EarlyStoppingConfig(BaseModel): - """MLPerf-style early-stopping percentile estimates (on by default). - - Adds conservative, confidence-backed estimates of the tail percentiles to the - TTFT / TPOT / latency metrics in ``result_summary.json``. Computed once at run - COMPLETE from data the aggregator already keeps (hot path untouched), and the - output field is additive — so it is on by default; ``enabled: false`` is the - single opt-out (e.g. for consumers that strictly validate the summary schema). - Percentile targets, confidence (0.99), and tolerance (0.0) are LoadGen-parity - constants in ``metrics/early_stopping.py``, not knobs. Estimate-only: no - target-latency pass/fail and no dynamic mid-run halt. See ``docs/early_stopping.md``. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - enabled: Annotated[ - bool, - cyclopts.Parameter( - alias="--early-stopping", # --no-early-stopping is the meaningful opt-out - help="Report MLPerf early-stopping percentile estimates for TTFT/TPOT/latency", - ), - ] = Field(True, description="Early-stopping percentile estimates (default on)") - - -@cyclopts.Parameter(name="*") -class Settings(BaseModel): - """Test settings.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) - load_pattern: LoadPattern = Field(default_factory=LoadPattern) - client: HTTPClientConfig = Field(default_factory=HTTPClientConfig) - drain: DrainConfig = Field( - default_factory=DrainConfig, - description="Per-phase in-flight response drain timeout configuration", - ) - warmup: WarmupConfig = Field(default_factory=WarmupConfig) - profiling: ProfilingConfig = Field(default_factory=ProfilingConfig) - early_stopping: EarlyStoppingConfig = Field( - default_factory=EarlyStoppingConfig, - description="MLPerf early-stopping percentile estimates (on by default; enabled: false opts out)", - ) - service_ready_timeout_s: Annotated[ - float, - cyclopts.Parameter( - alias="--service-ready-timeout", - help="Seconds to wait for metrics/event-logger services to start", - ), - ] = Field( - default=30.0, - ge=0, - description="Seconds to wait for metrics-aggregator/event-logger services to become ready.", - ) - - -class OfflineSettings(Settings): - """Offline mode default settings.""" - - load_pattern: Annotated[LoadPattern, cyclopts.Parameter(show=False)] = Field( - default_factory=lambda: LoadPattern(type=LoadPatternType.MAX_THROUGHPUT) - ) +__all__ = [ + "APIType", + "AccuracyConfig", + "AgenticInferenceConfig", + "AuditConfig", + "AuditTestId", + "BenchmarkConfig", + "Dataset", + "DatasetType", + "EarlyStoppingConfig", + "EndpointConfig", + "EvalMethod", + "LoadPattern", + "LoadPatternType", + "ModelParams", + "OSLDistribution", + "OSLDistributionType", + "OfflineBenchmarkConfig", + "OfflineSettings", + "OnlineBenchmarkConfig", + "OnlineSettings", + "OutputCachingTestConfig", + "ProfilerEngine", + "ProfilingConfig", + "RuntimeConfig", + "ScorerMethod", + "Settings", + "StreamingMode", + "SubmissionReference", + "TestMode", + "TestType", + "Timeouts", + "WarmupConfig", +] +logger = logging.getLogger(__name__) -class OnlineSettings(Settings): - """Online mode default settings.""" - pass class EndpointConfig(BaseModel): @@ -1078,10 +196,6 @@ class BenchmarkConfig(WithUpdatesMixin, BaseModel): Path | None, cyclopts.Parameter(alias="--report-dir", help="Report output directory"), ] = None - timeout: Annotated[ - float | None, - cyclopts.Parameter(alias="--timeout", help="Global timeout in seconds"), - ] = None # verbose is handled by cyclopts meta app (-v flag), not here verbose: Annotated[bool, cyclopts.Parameter(show=False)] = Field( False, description="Enable verbose logging" @@ -1120,7 +234,6 @@ def _resolve_and_validate(self) -> Self: Validation: - Workers must be -1 (auto) or >= 1 - - max_duration_ms >= min_duration_ms >= 0 - No duplicate dataset (name, type) pairs - Load pattern must match test type """ diff --git a/src/inference_endpoint/config/settings.py b/src/inference_endpoint/config/settings.py new file mode 100644 index 000000000..f90893859 --- /dev/null +++ b/src/inference_endpoint/config/settings.py @@ -0,0 +1,343 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test settings models (the ``settings:`` block). + +Split criterion: one module per config domain; the runtime/load-pattern/ +warmup/profiling settings and the ``Settings`` aggregate live here. +``config/schema.py`` re-exports the public surface. +""" + +from __future__ import annotations + +from typing import Annotated, Any, Self + +import cyclopts +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SerializerFunctionWrapHandler, + field_validator, + model_serializer, + model_validator, +) + +from ..endpoint_client.config import HTTPClientConfig +from .enums import LoadPatternType, ProfilerEngine +from .timeouts import Timeouts + + +class RuntimeConfig(BaseModel): + """Runtime configuration. + + Sample count priority (in RuntimeSettings.total_samples_to_issue()): + 1. n_samples_to_issue (if specified) — explicit override + 2. All dataset samples — issue the dataset once + + ``max_duration_ms`` is a workload duration (part of the benchmark + definition), not a give-up deadline — those live in ``settings.timeouts``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + max_duration_ms: int | None = Field( + None, + gt=0, + description="Maximum test duration in ms (None for no limit)", + ) + + @field_validator("max_duration_ms", mode="before") + @classmethod + def _parse_duration_suffix(cls, v: object) -> object: + """Accept duration with unit suffix: 600s, 10m, 600000ms, or plain int (ms).""" + if isinstance(v, str): + v = v.strip() + if v.endswith("ms"): + return int(v[:-2]) + if v.endswith("m"): + return int(float(v[:-1]) * 60_000) + if v.endswith("s"): + return int(float(v[:-1]) * 1000) + return v + + n_samples_to_issue: Annotated[ + int | None, + cyclopts.Parameter(alias="--num-samples", help="Sample count override"), + ] = Field(None, gt=0) + scheduler_random_seed: int = Field(42, description="Scheduler RNG seed") + dataloader_random_seed: int = Field(42, description="Dataloader RNG seed") + + +@cyclopts.Parameter(name="*") +class LoadPattern(BaseModel): + """Load pattern configuration. + + Different patterns use target_qps differently: + - max_throughput: target_qps used for calculating total queries (offline, optional with default) + - poisson: target_qps sets scheduler rate (online, required - validated) + - concurrency: issue at fixed target_concurrency (online, required - validated) + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: Annotated[ + LoadPatternType, + cyclopts.Parameter(name="--load-pattern", help="Load pattern type"), + ] = LoadPatternType.MAX_THROUGHPUT + target_qps: Annotated[ + float | None, cyclopts.Parameter(alias="--target-qps", help="Target QPS") + ] = Field(None, gt=0) + target_concurrency: Annotated[ + int | None, + cyclopts.Parameter(alias="--concurrency", help="Concurrent requests"), + ] = Field(None, gt=0) + + # TODO(vir): remove once the formal tail-cutting mechanism lands. + use_legacy_loadgen_qps_metrics: Annotated[ + bool, + cyclopts.Parameter( + negative="--no-use-legacy-loadgen-qps-metrics", + help=( + "Only applies to the poisson load pattern. Report QPS/TPS using " + "the legacy MLPerf LoadGen Server 'completed' definition — (completed-1)/T " + "and tokens/T, T = first issued request to completion of the " + "last-issued request (see mlcommons/inference loadgen/results.cc). " + "--no-... uses endpoints-native completed/duration. Ignored for " + "non-poisson patterns." + ), + ), + ] = True + + @model_serializer(mode="wrap") + def _serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + # use_legacy_loadgen_qps_metrics only applies to poisson; drop it from + # the serialized form (and thus YAML templates) for other patterns. + data = handler(self) + if self.type != LoadPatternType.POISSON: + data.pop("use_legacy_loadgen_qps_metrics", None) + return data + + @model_validator(mode="after") + def _validate_completeness(self) -> Self: + if self.type == LoadPatternType.POISSON and ( + self.target_qps is None or self.target_qps <= 0 + ): + raise ValueError("Poisson requires --target-qps (e.g., --target-qps 100)") + if self.type == LoadPatternType.CONCURRENCY and ( + not self.target_concurrency or self.target_concurrency <= 0 + ): + raise ValueError( + "Concurrency requires --concurrency (e.g., --concurrency 10)" + ) + if self.type == LoadPatternType.AGENTIC_INFERENCE and ( + not self.target_concurrency or self.target_concurrency <= 0 + ): + raise ValueError( + "Agentic inference requires --concurrency (e.g., --concurrency 96)" + ) + return self + + def __str__(self) -> str: + """Human-readable "type (param=value)" form for logging, e.g. + ``concurrency (target_concurrency=7)`` / ``poisson (target_qps=10.0)``. + Patterns without a driving parameter render as just the type name. + """ + if self.type in ( + LoadPatternType.CONCURRENCY, + LoadPatternType.AGENTIC_INFERENCE, + ): + return f"{self.type.value} (target_concurrency={self.target_concurrency})" + if self.type == LoadPatternType.POISSON: + return f"{self.type.value} (target_qps={self.target_qps})" + return self.type.value + + +@cyclopts.Parameter(name="*") +class WarmupConfig(BaseModel): + """Warmup phase configuration. Runs before the performance phase; results are not recorded.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + enabled: Annotated[ + bool, + cyclopts.Parameter( + alias="--warmup", help="Enable warmup phase before performance run" + ), + ] = Field(False, description="Enable warmup phase before performance run") + n_requests: Annotated[ + int | None, + cyclopts.Parameter( + alias="--warmup-requests", + help="Warmup request count (None = full dataset once)", + ), + ] = Field(None, gt=0, description="Warmup request count (None = full dataset once)") + salt: Annotated[ + bool, + cyclopts.Parameter( + alias="--warmup-salt", + help="Prepend a unique random hex salt to each warmup prompt", + ), + ] = Field( + True, description="Prepend a unique random hex salt to each warmup prompt" + ) + drain: Annotated[ + bool, + cyclopts.Parameter( + alias="--warmup-drain", + help="Drain in-flight warmup requests before starting the performance phase", + ), + ] = Field( + False, + description="Drain in-flight warmup requests before starting the performance phase", + ) + warmup_random_seed: Annotated[ + int, + cyclopts.Parameter( + alias="--warmup-seed", + help="RNG seed for warmup scheduling and sample ordering", + ), + ] = Field(42, description="RNG seed for warmup scheduling and sample ordering") + + +@cyclopts.Parameter(name="*") +class ProfilingConfig(BaseModel): + """Client-side trigger for the server's profiler. + + When ``engine`` is set, fires POST ```` at performance-phase + begin and POST ```` at performance-phase end. URLs are derived + using the engine-specific protocol from ``urls`` when set, otherwise + from ``endpoint_config.endpoints``. + Server must be launched with profiling enabled (e.g. vLLM's + ``--profiler-config.profiler=cuda|torch``); the schedule + (``delay_iterations``, ``max_iterations``) is set there, not here. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + engine: Annotated[ + ProfilerEngine | None, + cyclopts.Parameter( + alias="--profile", + help="Profile the named inference engine around the performance phase", + ), + ] = Field( + None, + description="Profile the named inference engine around the performance phase", + ) + urls: Annotated[ + list[str] | None, + cyclopts.Parameter( + alias="--profile-urls", + help="Override URL(s) for profiler triggers; " + "defaults to endpoint_config.endpoints", + negative="", + ), + ] = Field( + None, + description="URL(s) the profiler start/stop triggers are derived from. " + "When None, derived from endpoint_config.endpoints instead. Use when " + "the profiler admin endpoint differs from the inference endpoint.", + ) + + @field_validator("urls", mode="after") + @classmethod + def _validate_url_scheme(cls, v: list[str] | None) -> list[str] | None: + if v is None: + return v + for url in v: + if not url.startswith(("http://", "https://")): + raise ValueError( + f"Profiling endpoint URL must include scheme " + f"(http:// or https://), got: {url!r}" + ) + return v + + +class EarlyStoppingConfig(BaseModel): + """MLPerf-style early-stopping percentile estimates (on by default). + + Adds conservative, confidence-backed estimates of the tail percentiles to the + TTFT / TPOT / latency metrics in ``result_summary.json``. Computed once at run + COMPLETE from data the aggregator already keeps (hot path untouched), and the + output field is additive — so it is on by default; ``enabled: false`` is the + single opt-out (e.g. for consumers that strictly validate the summary schema). + Percentile targets, confidence (0.99), and tolerance (0.0) are LoadGen-parity + constants in ``metrics/early_stopping.py``, not knobs. Estimate-only: no + target-latency pass/fail and no dynamic mid-run halt. See ``docs/early_stopping.md``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + enabled: Annotated[ + bool, + cyclopts.Parameter( + alias="--early-stopping", # --no-early-stopping is the meaningful opt-out + help="Report MLPerf early-stopping percentile estimates for TTFT/TPOT/latency", + ), + ] = Field(True, description="Early-stopping percentile estimates (default on)") + + +@cyclopts.Parameter(name="*") +class Settings(BaseModel): + """Test settings.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) + load_pattern: LoadPattern = Field(default_factory=LoadPattern) + client: HTTPClientConfig = Field(default_factory=HTTPClientConfig) + timeouts: Timeouts = Field( + default_factory=Timeouts, + description="All global waits and deadlines (see config/timeouts.py)", + ) + warmup: WarmupConfig = Field(default_factory=WarmupConfig) + profiling: ProfilingConfig = Field(default_factory=ProfilingConfig) + early_stopping: EarlyStoppingConfig = Field( + default_factory=EarlyStoppingConfig, + description="MLPerf early-stopping percentile estimates (on by default; enabled: false opts out)", + ) + metrics_tokenizer_workers: Annotated[ + int, + cyclopts.Parameter( + alias="--metrics-tokenizer-workers", + help=( + "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT in " + "the metrics aggregator. 0 defers all tokenization to the " + "end-of-run drain, which always uses the auto-sized sharded pool." + ), + ), + ] = Field( + 4, + ge=0, + description=( + "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT " + "(default: 4; 0 = defer everything to the end-of-run drain)." + ), + ) + + +class OfflineSettings(Settings): + """Offline mode default settings.""" + + load_pattern: Annotated[LoadPattern, cyclopts.Parameter(show=False)] = Field( + default_factory=lambda: LoadPattern(type=LoadPatternType.MAX_THROUGHPUT) + ) + + +class OnlineSettings(Settings): + """Online mode default settings.""" + + pass diff --git a/src/inference_endpoint/config/templates/concurrency_template.yaml b/src/inference_endpoint/config/templates/concurrency_template.yaml index b0d9df61e..e16ed4f51 100644 --- a/src/inference_endpoint/config/templates/concurrency_template.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template.yaml @@ -10,8 +10,6 @@ datasets: # Dataset configs prompt: text_input settings: runtime: - min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m) - max_duration_ms: 0 # Maximum test duration in ms (0 for no limit) n_samples_to_issue: null # Sample count override load_pattern: type: concurrency # Load pattern type | options: max_throughput, poisson, concurrency, agentic_inference, burst, step diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 217a762bc..d2c3efafe 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -51,8 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m) - max_duration_ms: 0 # Maximum test duration in ms (0 for no limit) + max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed dataloader_random_seed: 42 # Dataloader RNG seed @@ -77,19 +76,20 @@ settings: linger: -1 # ZMQ linger on close (-1=block until sent) immediate: 1 # ZMQ IMMEDIATE (1=only enqueue on ready) stream_all_chunks: false # Stream all chunks to main thread (caution: perf overhead) - worker_initialization_timeout: 60.0 # Worker init timeout (seconds) - worker_graceful_shutdown_wait: 0.5 # Post-run graceful shutdown wait (seconds) - worker_force_kill_timeout: 0.5 # Force kill timeout after graceful wait (seconds) insecure: false # Skip TLS certificate verification max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system - drain: # Per-phase in-flight response drain timeout configuration - warmup_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_timeout_s: 240.0 # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely) - metrics_drain_timeout_s: 0.0 # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (default: 0 = unlimited). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. - metrics_tokenizer_workers: 4 # In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT (default: 4; 0 = defer everything to the end-of-run drain). + timeouts: # All global waits and deadlines (see config/timeouts.py) + run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. + warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) + performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. + worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) + worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) + worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) @@ -101,14 +101,13 @@ settings: urls: null # URL(s) the profiler start/stop triggers are derived from. When None, derived from endpoint_config.endpoints instead. Use when the profiler admin endpoint differs from the inference endpoint. early_stopping: # MLPerf early-stopping percentile estimates (on by default; enabled: false opts out) enabled: true # Early-stopping percentile estimates (default on) - service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. + metrics_tokenizer_workers: 4 # In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT (default: 4; 0 = defer everything to the end-of-run drain). endpoint_config: endpoints: # Endpoint URL(s). Must include scheme, e.g. 'http://host:port'. - http://localhost:8000 api_key: null # API key api_type: openai # API type: openai, sglang, or videogen | options: openai, openai_completions, sglang, videogen report_dir: null # Report output directory -timeout: null # Global timeout in seconds verbose: false # Enable verbose logging enable_cpu_affinity: true # NUMA-aware CPU pinning audit: null # Compliance audit config (YAML only). When set, runs the audit after the main benchmark. diff --git a/src/inference_endpoint/config/templates/offline_template.yaml b/src/inference_endpoint/config/templates/offline_template.yaml index 3305aa1ed..e4d2de8a6 100644 --- a/src/inference_endpoint/config/templates/offline_template.yaml +++ b/src/inference_endpoint/config/templates/offline_template.yaml @@ -10,8 +10,6 @@ datasets: # Dataset configs prompt: text_input settings: runtime: - min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m) - max_duration_ms: 0 # Maximum test duration in ms (0 for no limit) n_samples_to_issue: null # Sample count override endpoint_config: endpoints: # Endpoint URL(s). Must include scheme, e.g. 'http://host:port'. diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index 587735956..7250f2ca5 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -51,8 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m) - max_duration_ms: 0 # Maximum test duration in ms (0 for no limit) + max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed dataloader_random_seed: 42 # Dataloader RNG seed @@ -77,19 +76,20 @@ settings: linger: -1 # ZMQ linger on close (-1=block until sent) immediate: 1 # ZMQ IMMEDIATE (1=only enqueue on ready) stream_all_chunks: false # Stream all chunks to main thread (caution: perf overhead) - worker_initialization_timeout: 60.0 # Worker init timeout (seconds) - worker_graceful_shutdown_wait: 0.5 # Post-run graceful shutdown wait (seconds) - worker_force_kill_timeout: 0.5 # Force kill timeout after graceful wait (seconds) insecure: false # Skip TLS certificate verification max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system - drain: # Per-phase in-flight response drain timeout configuration - warmup_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_timeout_s: 240.0 # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely) - metrics_drain_timeout_s: 0.0 # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (default: 0 = unlimited). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. - metrics_tokenizer_workers: 4 # In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT (default: 4; 0 = defer everything to the end-of-run drain). + timeouts: # All global waits and deadlines (see config/timeouts.py) + run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. + warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) + performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. + worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) + worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) + worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) @@ -101,14 +101,13 @@ settings: urls: null # URL(s) the profiler start/stop triggers are derived from. When None, derived from endpoint_config.endpoints instead. Use when the profiler admin endpoint differs from the inference endpoint. early_stopping: # MLPerf early-stopping percentile estimates (on by default; enabled: false opts out) enabled: true # Early-stopping percentile estimates (default on) - service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. + metrics_tokenizer_workers: 4 # In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT (default: 4; 0 = defer everything to the end-of-run drain). endpoint_config: endpoints: # Endpoint URL(s). Must include scheme, e.g. 'http://host:port'. - http://localhost:8000 api_key: null # API key api_type: openai # API type: openai, sglang, or videogen | options: openai, openai_completions, sglang, videogen report_dir: null # Report output directory -timeout: null # Global timeout in seconds verbose: false # Enable verbose logging enable_cpu_affinity: true # NUMA-aware CPU pinning audit: null # Compliance audit config (YAML only). When set, runs the audit after the main benchmark. diff --git a/src/inference_endpoint/config/templates/online_template.yaml b/src/inference_endpoint/config/templates/online_template.yaml index 501670691..65180d2e6 100644 --- a/src/inference_endpoint/config/templates/online_template.yaml +++ b/src/inference_endpoint/config/templates/online_template.yaml @@ -10,8 +10,6 @@ datasets: # Dataset configs prompt: text_input settings: runtime: - min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m) - max_duration_ms: 0 # Maximum test duration in ms (0 for no limit) n_samples_to_issue: null # Sample count override load_pattern: type: poisson # Load pattern type | options: max_throughput, poisson, concurrency, agentic_inference, burst, step diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 95bc8555c..8b0d18f39 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -51,8 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: 600000 # Min duration (ms, or with suffix: 600s, 10m) - max_duration_ms: 0 # Maximum test duration in ms (0 for no limit) + max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed dataloader_random_seed: 42 # Dataloader RNG seed @@ -78,19 +77,20 @@ settings: linger: -1 # ZMQ linger on close (-1=block until sent) immediate: 1 # ZMQ IMMEDIATE (1=only enqueue on ready) stream_all_chunks: false # Stream all chunks to main thread (caution: perf overhead) - worker_initialization_timeout: 60.0 # Worker init timeout (seconds) - worker_graceful_shutdown_wait: 0.5 # Post-run graceful shutdown wait (seconds) - worker_force_kill_timeout: 0.5 # Force kill timeout after graceful wait (seconds) insecure: false # Skip TLS certificate verification max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system - drain: # Per-phase in-flight response drain timeout configuration - warmup_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_timeout_s: 240.0 # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely) - metrics_drain_timeout_s: 0.0 # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (default: 0 = unlimited). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. - metrics_tokenizer_workers: 4 # In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT (default: 4; 0 = defer everything to the end-of-run drain). + timeouts: # All global waits and deadlines (see config/timeouts.py) + run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. + warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) + performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. + worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) + worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) + worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) @@ -102,14 +102,13 @@ settings: urls: null # URL(s) the profiler start/stop triggers are derived from. When None, derived from endpoint_config.endpoints instead. Use when the profiler admin endpoint differs from the inference endpoint. early_stopping: # MLPerf early-stopping percentile estimates (on by default; enabled: false opts out) enabled: true # Early-stopping percentile estimates (default on) - service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. + metrics_tokenizer_workers: 4 # In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT (default: 4; 0 = defer everything to the end-of-run drain). endpoint_config: endpoints: # Endpoint URL(s). Must include scheme, e.g. 'http://host:port'. - http://localhost:8000 api_key: null # API key api_type: openai # API type: openai, sglang, or videogen | options: openai, openai_completions, sglang, videogen report_dir: null # Report output directory -timeout: null # Global timeout in seconds verbose: false # Enable verbose logging enable_cpu_affinity: true # NUMA-aware CPU pinning audit: null # Compliance audit config (YAML only). When set, runs the audit after the main benchmark. diff --git a/src/inference_endpoint/config/templates/submission_template.yaml b/src/inference_endpoint/config/templates/submission_template.yaml index ac3c2b11d..70b9925a7 100644 --- a/src/inference_endpoint/config/templates/submission_template.yaml +++ b/src/inference_endpoint/config/templates/submission_template.yaml @@ -46,7 +46,6 @@ datasets: settings: runtime: - min_duration_ms: 600000 # 10 minutes max_duration_ms: 1800000 # 30 minutes scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling diff --git a/src/inference_endpoint/config/timeouts.py b/src/inference_endpoint/config/timeouts.py new file mode 100644 index 000000000..c767ad445 --- /dev/null +++ b/src/inference_endpoint/config/timeouts.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Global waits and deadlines (the ``settings.timeouts`` block). + +Split criterion: one module per config domain; every global time knob that +bounds how long the harness waits — startup readiness, per-phase drains, the +worker lifecycle, and the whole-run watchdog — lives here. Workload durations +(``runtime.max_duration_ms``) are part of the benchmark definition, not waits, +and stay in ``runtime``. Dataset-scoped time knobs (e.g. agentic +``turn_timeout_s``) stay in their dataset config blocks. +""" + +from __future__ import annotations + +from typing import Annotated + +import cyclopts +from pydantic import BaseModel, ConfigDict, Field + +from ..utils import WithUpdatesMixin + + +@cyclopts.Parameter(name="*") +class Timeouts(WithUpdatesMixin, BaseModel): + """All global waits and deadlines. ``None`` = wait indefinitely / off. + + Reaching an optional deadline means something is stuck; ``run_timeout_s`` + is the whole-run watchdog — when it fires the run is aborted and the + report is marked INTERRUPTED. It never derives or caps the other + deadlines. Workload durations (``runtime.max_duration_ms``) are NOT + timeouts and do not live here. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + run_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--timeout", + help=( + "Whole-run watchdog in seconds (None = off). Firing aborts the " + "run and marks the report INTERRUPTED." + ), + ), + ] = Field( + None, + gt=0, + description=( + "Whole-run watchdog in seconds (None = off). Covers every phase " + "including drains; firing aborts the run, marks the report " + "INTERRUPTED, and exits non-zero. Never derives per-stage deadlines." + ), + ) + service_ready_timeout_s: Annotated[ + float, + cyclopts.Parameter( + alias="--service-ready-timeout", + help="Seconds to wait for metrics/event-logger services to start", + ), + ] = Field( + 30.0, + ge=0, + description="Seconds to wait for metrics-aggregator/event-logger services to become ready.", + ) + warmup_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--warmup-drain-timeout", + help="Warmup drain timeout in seconds (None = wait indefinitely)", + ), + ] = Field( + 240.0, + gt=0, + description="Warmup drain timeout in seconds (None = wait indefinitely)", + ) + performance_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--performance-drain-timeout", + help="Performance drain timeout in seconds (None = wait indefinitely)", + ), + ] = Field( + None, + gt=0, + description="Performance drain timeout in seconds (None = wait indefinitely)", + ) + accuracy_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--accuracy-drain-timeout", + help="Accuracy drain timeout in seconds (None = wait indefinitely)", + ), + ] = Field( + None, + gt=0, + description=( + "Accuracy drain timeout in seconds (None = wait indefinitely; " + "accuracy is unbounded by default because every sample must complete)" + ), + ) + metrics_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--metrics-drain-timeout", + help=( + "Wall-clock budget (seconds) for the metrics aggregator to finish " + "tokenizing buffered samples after the run ends " + "(None = wait indefinitely)" + ), + ), + ] = Field( + None, + gt=0, + description=( + "Wall-clock budget (seconds) to finish tokenizing buffered samples " + "after ENDED (None = wait indefinitely). An incomplete drain is " + "surfaced via n_pending_tasks > 0, never silently dropped." + ), + ) + worker_initialization_timeout_s: float = Field( + 60.0, ge=0, description="Endpoint-client worker init timeout (seconds)" + ) + worker_graceful_shutdown_wait_s: float = Field( + 0.5, + ge=0, + description="Endpoint-client post-run graceful shutdown wait (seconds)", + ) + worker_force_kill_timeout_s: float = Field( + 0.5, + ge=0, + description="Endpoint-client force kill timeout after graceful wait (seconds)", + ) diff --git a/src/inference_endpoint/endpoint_client/config.py b/src/inference_endpoint/endpoint_client/config.py index b2839996d..6802a2851 100644 --- a/src/inference_endpoint/endpoint_client/config.py +++ b/src/inference_endpoint/endpoint_client/config.py @@ -187,15 +187,24 @@ class HTTPClientConfig(WithUpdatesMixin, BaseModel): False, description="Stream all chunks to main thread (caution: perf overhead)" ) - # Worker lifecycle timeouts - worker_initialization_timeout: float = Field( - 60.0, description="Worker init timeout (seconds)" - ) - worker_graceful_shutdown_wait: float = Field( - 0.5, description="Post-run graceful shutdown wait (seconds)" + # Worker lifecycle timeouts — runtime carriers. The authoritative user + # knobs live in settings.timeouts; setup copies them here (no CLI flag, + # never serialized). WithUpdatesMixin.with_updates reads exclude=True + # fields directly, so copies preserve the injected values. + worker_initialization_timeout_s: Annotated[ + float, cyclopts.Parameter(parse=False) + ] = Field(60.0, exclude=True, description="Worker init timeout (seconds)") + worker_graceful_shutdown_wait_s: Annotated[ + float, cyclopts.Parameter(parse=False) + ] = Field( + 0.5, exclude=True, description="Post-run graceful shutdown wait (seconds)" ) - worker_force_kill_timeout: float = Field( - 0.5, description="Force kill timeout after graceful wait (seconds)" + worker_force_kill_timeout_s: Annotated[float, cyclopts.Parameter(parse=False)] = ( + Field( + 0.5, + exclude=True, + description="Force kill timeout after graceful wait (seconds)", + ) ) # Set to True to skip certificate verification (e.g. self-signed certs). diff --git a/src/inference_endpoint/endpoint_client/worker_manager.py b/src/inference_endpoint/endpoint_client/worker_manager.py index ae0d194df..bd0304447 100644 --- a/src/inference_endpoint/endpoint_client/worker_manager.py +++ b/src/inference_endpoint/endpoint_client/worker_manager.py @@ -92,7 +92,7 @@ async def initialize(self) -> None: except TimeoutError as e: raise TimeoutError( - f"Workers failed to initialize within {self.http_config.worker_initialization_timeout}s" + f"Workers failed to initialize within {self.http_config.worker_initialization_timeout_s}s" ) from e finally: @@ -130,7 +130,7 @@ def _pin_workers(self) -> None: async def _wait_for_workers_with_liveness_check(self) -> None: """Wait for workers, checking liveness at 10% intervals.""" - timeout = self.http_config.worker_initialization_timeout + timeout = self.http_config.worker_initialization_timeout_s check_interval = timeout * 0.10 if timeout else 1.0 start = time.monotonic() @@ -165,7 +165,7 @@ async def shutdown(self) -> None: if worker.is_alive(): worker.terminate() - await asyncio.sleep(self.http_config.worker_graceful_shutdown_wait) + await asyncio.sleep(self.http_config.worker_graceful_shutdown_wait_s) # Force kill remaining for worker in self.workers: @@ -176,7 +176,7 @@ async def shutdown(self) -> None: await asyncio.gather( *( asyncio.to_thread( - worker.join, timeout=self.http_config.worker_force_kill_timeout + worker.join, timeout=self.http_config.worker_force_kill_timeout_s ) for worker in self.workers ) diff --git a/tests/integration/commands/test_accuracy_pipeline.py b/tests/integration/commands/test_accuracy_pipeline.py index 3f6c66055..ab6cac027 100644 --- a/tests/integration/commands/test_accuracy_pipeline.py +++ b/tests/integration/commands/test_accuracy_pipeline.py @@ -35,7 +35,6 @@ LoadPattern, LoadPatternType, ModelParams, - RuntimeConfig, Settings, StreamingMode, TestMode, @@ -119,7 +118,6 @@ def test_accuracy_scoring_with_echo_server( ), ], settings=Settings( - runtime=RuntimeConfig(min_duration_ms=0), load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), client=HTTPClientConfig( num_workers=1, warmup_connections=0, max_connections=10 diff --git a/tests/integration/commands/test_benchmark_command.py b/tests/integration/commands/test_benchmark_command.py index ec9f5cb56..1aec94185 100644 --- a/tests/integration/commands/test_benchmark_command.py +++ b/tests/integration/commands/test_benchmark_command.py @@ -42,7 +42,6 @@ from inference_endpoint.endpoint_client.config import HTTPClientConfig _TEST_SETTINGS = Settings( - runtime=RuntimeConfig(min_duration_ms=0), load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), client=HTTPClientConfig(num_workers=1, warmup_connections=0, max_connections=10), ) @@ -61,8 +60,10 @@ def _config(endpoint_url: str, dataset_path: str, **overrides) -> BenchmarkConfi def _poisson_settings(target_qps: float, duration_s: int = 2) -> Settings: + # Pin the workload length via an explicit sample count equivalent to + # target_qps * duration_s. return Settings( - runtime=RuntimeConfig(min_duration_ms=duration_s * 1000), + runtime=RuntimeConfig(n_samples_to_issue=int(target_qps * duration_s)), load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=target_qps), client=HTTPClientConfig( num_workers=1, warmup_connections=0, max_connections=10 @@ -121,7 +122,7 @@ def test_concurrency_benchmark( type=TestType.ONLINE, model_params=ModelParams(name="echo-server", streaming=streaming), settings=Settings( - runtime=RuntimeConfig(min_duration_ms=2000), + runtime=RuntimeConfig(n_samples_to_issue=40), load_pattern=LoadPattern( type=LoadPatternType.CONCURRENCY, target_concurrency=4 ), @@ -176,7 +177,6 @@ def test_mode_logging(self, mock_http_echo_server, ds_dataset_path, caplog): ( TestType.OFFLINE, Settings( - runtime=RuntimeConfig(min_duration_ms=0), load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), client=HTTPClientConfig( num_workers=1, warmup_connections=0, max_connections=10 @@ -186,7 +186,6 @@ def test_mode_logging(self, mock_http_echo_server, ds_dataset_path, caplog): ( TestType.ONLINE, Settings( - runtime=RuntimeConfig(min_duration_ms=0), load_pattern=LoadPattern( type=LoadPatternType.CONCURRENCY, target_concurrency=1 ), @@ -281,7 +280,6 @@ def test_cli_run_dispatches_main_run_before_audit( model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), datasets=[Dataset(path=ds_dataset_path, type=DatasetType.PERFORMANCE)], settings=Settings( - runtime=RuntimeConfig(min_duration_ms=0), load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), client=HTTPClientConfig( num_workers=1, warmup_connections=0, max_connections=10 @@ -377,8 +375,8 @@ def _resolve_template(template_path: Path, server_url: str) -> dict: # The other 5 templates benefit from warm module / IPC caches and don't # need the headroom. 120 s is a generous safety margin that does not # change the production default, only this integration test. - data["settings"].setdefault("client", {}) - data["settings"]["client"]["worker_initialization_timeout"] = 120.0 + data["settings"].setdefault("timeouts", {}) + data["settings"]["timeouts"]["worker_initialization_timeout_s"] = 120.0 # Accuracy datasets can't run e2e against echo server (no scorer), so keep only performance datasets. data["datasets"] = [ diff --git a/tests/integration/commands/test_cli.py b/tests/integration/commands/test_cli.py index cec491eb1..d8c1f7a84 100644 --- a/tests/integration/commands/test_cli.py +++ b/tests/integration/commands/test_cli.py @@ -284,8 +284,6 @@ def test_offline(self, mock_http_echo_server, ds_dataset_path, tmp_path): tmp_path, "benchmark", "offline", - "--duration", - "0", "--streaming", "off", ) @@ -308,8 +306,8 @@ def test_poisson(self, mock_http_echo_server, ds_dataset_path, tmp_path): "poisson", "--target-qps", "50", - "--duration", - "2000", + "--num-samples", + "100", ) assert r["n_samples_issued"] > 0 @@ -326,7 +324,7 @@ def test_concurrency(self, mock_http_echo_server, ds_dataset_path, tmp_path): "concurrency", "--concurrency", "4", - "--duration", - "2000", + "--num-samples", + "40", ) assert r["n_samples_issued"] > 0 diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py new file mode 100644 index 000000000..e4d811b01 --- /dev/null +++ b/tests/integration/commands/test_run_timeout.py @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Whole-run watchdog (settings.timeouts.run_timeout_s) integration tests. + +Locking invariant: a fired run watchdog must never produce a COMPLETE +report. The watchdog SIGTERMs the metrics aggregator (whose handler writes +an INTERRUPTED final snapshot) before stopping the session, and +``run_benchmark`` exits non-zero via ``ExecutionError``. +""" + +import json +from pathlib import Path + +import pytest +from inference_endpoint.commands.benchmark.execute import run_benchmark +from inference_endpoint.config.schema import ( + BenchmarkConfig, + Dataset, + DatasetType, + EndpointConfig, + LoadPattern, + LoadPatternType, + ModelParams, + RuntimeConfig, + Settings, + StreamingMode, + TestMode, + TestType, + WarmupConfig, +) +from inference_endpoint.config.timeouts import Timeouts +from inference_endpoint.endpoint_client.config import HTTPClientConfig +from inference_endpoint.exceptions import ExecutionError + +# Local character-level tokenizer: lets the metrics aggregator tokenize +# ISL/OSL without a HuggingFace Hub download (same trick as +# test_benchmark_command.py). +_CHAR_TOKENIZER_DIR = Path(__file__).resolve().parents[2] / "assets/tokenizers/char" + +_FAST_CLIENT = HTTPClientConfig(num_workers=1, warmup_connections=0, max_connections=10) + + +def _read_final_snapshot(report_dir: Path) -> dict: + snapshot_path = report_dir / "metrics" / "final_snapshot.json" + assert snapshot_path.exists(), "aggregator must still write a final snapshot" + return json.loads(snapshot_path.read_text()) + + +def _read_result_summary(report_dir: Path) -> dict: + return json.loads((report_dir / "performance" / "result_summary.json").read_text()) + + +@pytest.mark.integration +def test_run_timeout_produces_interrupted_report( + mock_http_echo_server, ds_dataset_path, tmp_path +): + """run_timeout_s firing mid-run aborts with an INTERRUPTED report.""" + config = BenchmarkConfig( + type=TestType.ONLINE, + endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), + model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), + datasets=[Dataset(path=str(ds_dataset_path), type=DatasetType.PERFORMANCE)], + report_dir=tmp_path, + settings=Settings( + load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), + client=_FAST_CLIENT, + # 600 samples at 5 QPS is a ~120 s workload, so only the watchdog + # can end the run. + runtime=RuntimeConfig(n_samples_to_issue=600), + timeouts=Timeouts(run_timeout_s=2.0), + warmup=WarmupConfig(enabled=False), + ), + ) + + with pytest.raises(ExecutionError, match="Run timeout"): + run_benchmark(config, TestMode.PERF) + + snapshot = _read_final_snapshot(tmp_path) + assert snapshot["state"] == "interrupted" + + # Locking invariant: a fired run watchdog must never yield a COMPLETE report. + summary = _read_result_summary(tmp_path) + assert summary["complete"] is False + + +@pytest.mark.integration +def test_generous_run_timeout_completes_normally( + mock_http_echo_server, ds_dataset_path, tmp_path +): + """A run_timeout_s far above the workload length never fires: the run + finishes cleanly and publishes a COMPLETE report.""" + config = BenchmarkConfig( + type=TestType.OFFLINE, + endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), + model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), + datasets=[Dataset(path=str(ds_dataset_path), type=DatasetType.PERFORMANCE)], + report_dir=tmp_path, + settings=Settings( + load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), + client=_FAST_CLIENT, + timeouts=Timeouts(run_timeout_s=300.0), + warmup=WarmupConfig(enabled=False), + ), + ) + + run_benchmark(config, TestMode.PERF) # must not raise + + snapshot = _read_final_snapshot(tmp_path) + assert snapshot["state"] == "complete" + summary = _read_result_summary(tmp_path) + assert summary["complete"] is True + + +@pytest.mark.integration +def test_run_timeout_during_metrics_drain_interrupts(mock_http_echo_server, tmp_path): + """The watchdog stays armed through the metrics drain. + + The session itself finishes quickly, but the aggregator is left with a + deliberately huge tokenization backlog (large prompts echoed back as + outputs, metrics_tokenizer_workers=0 so nothing tokenizes mid-run, and + the metrics drain unlimited). The watchdog must fire while the + aggregator drains, SIGTERM it, and surface the run as INTERRUPTED with + a non-zero exit. + """ + # ~25 MB of prompt text; the echo server doubles it into OSL, so the + # drain has ~50M characters to tokenize — far more than run_timeout_s + # allows on any hardware. + dataset_path = tmp_path / "big_prompts.jsonl" + prompt = "lorem ipsum " * 21_000 # ~250 KB per sample + with dataset_path.open("w") as f: + for i in range(100): + f.write(json.dumps({"prompt": f"{i} {prompt}"}) + "\n") + + report_dir = tmp_path / "report" + config = BenchmarkConfig( + type=TestType.OFFLINE, + endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), + model_params=ModelParams( + name=str(_CHAR_TOKENIZER_DIR), streaming=StreamingMode.OFF + ), + datasets=[Dataset(path=str(dataset_path), type=DatasetType.PERFORMANCE)], + report_dir=report_dir, + settings=Settings( + load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), + client=_FAST_CLIENT, + # Defer every ISL/OSL tokenization to the end-of-run drain. + metrics_tokenizer_workers=0, + # metrics_drain_timeout_s stays None (unlimited): only the + # run watchdog can end the drain. + timeouts=Timeouts(run_timeout_s=2.5), + warmup=WarmupConfig(enabled=False), + ), + ) + + with pytest.raises(ExecutionError, match="Run timeout"): + run_benchmark(config, TestMode.PERF) + + snapshot = _read_final_snapshot(report_dir) + assert snapshot["state"] == "interrupted" diff --git a/tests/integration/commands/test_warmup.py b/tests/integration/commands/test_warmup.py index 4622365e5..3c26302a0 100644 --- a/tests/integration/commands/test_warmup.py +++ b/tests/integration/commands/test_warmup.py @@ -96,7 +96,7 @@ def _offline_config( model_params=ModelParams(name="test-model", streaming=StreamingMode.OFF), datasets=[ConfigDataset(path=str(dataset_path), type=DatasetType.PERFORMANCE)], settings=OfflineSettings( - runtime=RuntimeConfig(min_duration_ms=0, n_samples_to_issue=n_perf_samples), + runtime=RuntimeConfig(n_samples_to_issue=n_perf_samples), load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), client=_MINIMAL_CLIENT, warmup=warmup, diff --git a/tests/unit/async_utils/services/test_launcher.py b/tests/unit/async_utils/services/test_launcher.py new file mode 100644 index 000000000..15f5c78fb --- /dev/null +++ b/tests/unit/async_utils/services/test_launcher.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import signal +import subprocess +import sys +from unittest.mock import MagicMock + +import pytest +from inference_endpoint.async_utils.services.launcher import ServiceLauncher + + +@pytest.mark.unit +def test_terminate_sigterms_only_exact_module_match(): + target = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + bystander = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + launcher = ServiceLauncher(MagicMock()) + launcher._procs = [target, bystander] + launcher._modules = ["svc.metrics_aggregator", "prefix.svc.metrics_aggregator"] + try: + launcher.terminate("svc.metrics_aggregator") + assert target.wait(timeout=5.0) == -signal.SIGTERM + assert bystander.poll() is None, ( + "terminate() must match the exact module name; a mere suffix match " + "must stay alive" + ) + finally: + for proc in (target, bystander): + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5.0) + + +@pytest.mark.unit +def test_terminate_ignores_already_exited_proc(): + dead = subprocess.Popen([sys.executable, "-c", "pass"]) + dead.wait(timeout=5.0) + launcher = ServiceLauncher(MagicMock()) + launcher._procs = [dead] + launcher._modules = ["svc.metrics_aggregator"] + + launcher.terminate("svc.metrics_aggregator") + + assert dead.returncode == 0 diff --git a/tests/unit/async_utils/transport/test_protocol.py b/tests/unit/async_utils/transport/test_protocol.py new file mode 100644 index 000000000..16dc8da6a --- /dev/null +++ b/tests/unit/async_utils/transport/test_protocol.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +from collections import deque + +import pytest +from inference_endpoint.async_utils.transport.protocol import MessageSubscriber + + +class _IntCodec: + def encode(self, item: int) -> tuple[bytes, bytes]: + return b"test____", str(item).encode() + + def decode(self, payload: bytes) -> int: + return int(payload) + + def on_decode_error(self, payload: bytes, exc: Exception) -> int | None: + return None + + +class _QueueSubscriber(MessageSubscriber[int]): + def __init__( + self, + loop: asyncio.AbstractEventLoop, + payloads: list[bytes], + *, + max_read_batch_size: int, + ) -> None: + super().__init__(_IntCodec(), "test://subscriber", loop) + self._payloads = deque(payloads) + self._max_read_batch_size = max_read_batch_size + self.batches: list[list[int]] = [] + self.received: list[int] = [] + self.done = asyncio.Event() + self.expected = len(payloads) + self.release = asyncio.Event() + self.block_processing = False + self.active = 0 + self.max_active = 0 + + def receive(self) -> bytes | None: + if not self._payloads: + raise StopIteration + return self._payloads.popleft() + + async def process(self, items: list[int]) -> None: + self.active += 1 + self.max_active = max(self.max_active, self.active) + if self.block_processing: + await self.release.wait() + self.batches.append(items) + self.received.extend(items) + self.active -= 1 + if len(self.received) >= self.expected: + self.done.set() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_subscriber_caps_each_read_and_reschedules_without_new_edge(): + subscriber = _QueueSubscriber( + asyncio.get_running_loop(), + [str(i).encode() for i in range(5)], + max_read_batch_size=2, + ) + + subscriber._on_readable() + await asyncio.wait_for(subscriber.done.wait(), timeout=1) + + assert subscriber.received == [0, 1, 2, 3, 4] + assert subscriber.batches == [[0, 1], [2, 3], [4]] + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_subscriber_processes_batches_single_flight_in_fifo_order(): + subscriber = _QueueSubscriber( + asyncio.get_running_loop(), [b"1"], max_read_batch_size=4 + ) + subscriber.block_processing = True + subscriber.expected = 2 + + subscriber._on_readable() + subscriber._payloads.append(b"2") + subscriber._on_readable() + await asyncio.sleep(0) + subscriber.release.set() + await asyncio.wait_for(subscriber.done.wait(), timeout=1) + + assert subscriber.received == [1, 2] + assert subscriber.max_active == 1 + + +@pytest.mark.unit +def test_none_payloads_count_toward_read_budget_and_close_cancels_resume(): + subscriber = _QueueSubscriber( + asyncio.new_event_loop(), + [None, None, b"3"], # type: ignore[list-item] + max_read_batch_size=2, + ) + try: + subscriber._on_readable() + + assert list(subscriber._payloads) == [b"3"] + assert subscriber._read_continuation is not None + + subscriber.close() + assert subscriber._read_continuation is None + finally: + subscriber.loop.close() diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index ff5f597f4..7f2460dac 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -48,6 +48,7 @@ finalize_benchmark, setup_benchmark, ) +from inference_endpoint.commands.benchmark.pipeline import _build_aggregator_args from inference_endpoint.commands.benchmark.profiling import ( ProfileController, _derive_profile_urls, @@ -60,7 +61,6 @@ AgenticInferenceConfig, BenchmarkConfig, DatasetType, - DrainConfig, LoadPattern, LoadPatternType, OfflineSettings, @@ -82,8 +82,9 @@ from inference_endpoint.config.schema import ( OnlineBenchmarkConfig as OnlineConfig, ) +from inference_endpoint.config.timeouts import Timeouts from inference_endpoint.config.utils import cli_error_formatter as _error_formatter -from inference_endpoint.core.types import QueryResult +from inference_endpoint.core.types import APIType, QueryResult from inference_endpoint.dataset_manager.dataset import Dataset from inference_endpoint.dataset_manager.predefined.swe_bench import SWEBench from inference_endpoint.endpoint_client.config import HTTPClientConfig @@ -266,15 +267,13 @@ def test_mode_defaults(self, cls, extra_kwargs, expected_type, expected_streamin config = cls(**_OFFLINE_KWARGS, **extra_kwargs) assert config.type == expected_type assert config.model_params.streaming == expected_streaming - assert config.settings.runtime.min_duration_ms == 600000 + assert config.settings.runtime.n_samples_to_issue is None @pytest.mark.unit def test_num_samples_override(self): config = OfflineConfig( **_OFFLINE_KWARGS, - settings=OfflineSettings( - runtime=RuntimeConfig(min_duration_ms=0, n_samples_to_issue=100) - ), + settings=OfflineSettings(runtime=RuntimeConfig(n_samples_to_issue=100)), ) assert config.settings.runtime.n_samples_to_issue == 100 @@ -332,30 +331,6 @@ def test_concurrency_injection_into_swe_bench_extras( assert acc_ds.accuracy_config.extras.get("workers") == expected_workers -class TestDurationSuffix: - """Test duration suffix parsing (600s, 10m, 600000ms, plain int).""" - - @pytest.mark.unit - @pytest.mark.parametrize( - "value, expected_ms", - [ - ("600s", 600000), - ("10m", 600000), - ("600000ms", 600000), - ("600000", 600000), - (600000, 600000), - ("0.5m", 30000), - ("1.5s", 1500), - ], - ) - def test_duration_suffix(self, value, expected_ms): - config = OfflineConfig( - **_OFFLINE_KWARGS, - settings=OfflineSettings(runtime=RuntimeConfig(min_duration_ms=value)), - ) - assert config.settings.runtime.min_duration_ms == expected_ms - - class TestDatasetParsing: """Test dataset string coercion through BenchmarkConfig construction.""" @@ -527,7 +502,7 @@ def test_from_config_handler(self, mock_run, tmp_path): config_file.write_text(yaml_content) from_config(config=config_file, timeout=42.0, mode=TestMode.BOTH) called_config, called_mode = mock_run.call_args[0] - assert called_config.timeout == 42.0 + assert called_config.settings.timeouts.run_timeout_s == 42.0 assert called_mode == TestMode.BOTH @pytest.mark.unit @@ -1034,69 +1009,6 @@ def test_warmup_default_in_settings(self): assert warmup.n_requests is None -class TestDrainConfig: - """Tests for DrainConfig schema model.""" - - @pytest.mark.unit - def test_defaults(self): - cfg = DrainConfig() - assert cfg.warmup_timeout_s == 240.0 - assert cfg.performance_timeout_s == 240.0 - assert cfg.accuracy_timeout_s is None - assert cfg.metrics_drain_timeout_s == 0.0 - - @pytest.mark.unit - @pytest.mark.parametrize( - "field", - ["warmup_timeout_s", "performance_timeout_s", "accuracy_timeout_s"], - ) - @pytest.mark.parametrize("value", [0, -1.0]) - def test_timeout_must_be_positive_or_none(self, field, value): - with pytest.raises(ValidationError): - DrainConfig(**{field: value}) - - @pytest.mark.unit - def test_metrics_drain_timeout_zero_is_valid(self): - cfg = DrainConfig(metrics_drain_timeout_s=0) - assert cfg.metrics_drain_timeout_s == 0.0 - - @pytest.mark.unit - def test_metrics_drain_timeout_negative_rejected(self): - with pytest.raises(ValidationError): - DrainConfig(metrics_drain_timeout_s=-1.0) - - @pytest.mark.unit - def test_extra_fields_rejected(self): - with pytest.raises(ValidationError): - DrainConfig(unknown_field=1) - - @pytest.mark.unit - def test_yaml_roundtrip(self, tmp_path): - yaml_content = """ -type: "offline" -model_params: - name: "test-model" -endpoint_config: - endpoints: ["http://test:8000"] -datasets: - - path: "test.jsonl" -settings: - drain: - warmup_timeout_s: 12.5 - performance_timeout_s: 30.0 - accuracy_timeout_s: null - metrics_drain_timeout_s: 300.0 -""" - config_file = tmp_path / "drain.yaml" - config_file.write_text(yaml_content) - config = BenchmarkConfig.from_yaml_file(config_file) - drain = config.settings.drain - assert drain.warmup_timeout_s == 12.5 - assert drain.performance_timeout_s == 30.0 - assert drain.accuracy_timeout_s is None - assert drain.metrics_drain_timeout_s == 300.0 - - class TestAggregatorArgs: """Tests that metrics aggregator subprocess args are correctly forwarded.""" @@ -1130,7 +1042,7 @@ def _make_ctx(self, config, tmp_path): @pytest.mark.asyncio @pytest.mark.parametrize( "timeout_s, expected_flag", - [(120.0, "120.0"), (0.0, "0.0"), (60.0, "60.0")], + [(120.0, "120.0"), (None, "0"), (60.0, "60.0")], ) async def test_drain_timeout_forwarded_to_aggregator_args( self, tmp_path, timeout_s, expected_flag @@ -1138,7 +1050,7 @@ async def test_drain_timeout_forwarded_to_aggregator_args( config = OfflineConfig( **_OFFLINE_KWARGS, settings=OfflineSettings( - drain=DrainConfig(metrics_drain_timeout_s=timeout_s) + timeouts=Timeouts(metrics_drain_timeout_s=timeout_s) ), ) ctx = self._make_ctx(config, tmp_path) @@ -1183,12 +1095,29 @@ async def _capture_launch(service_configs, *, timeout): idx = args.index("--drain-timeout") assert args[idx + 1] == expected_flag + @pytest.mark.unit + def test_none_drain_timeout_builds_unlimited_argv(self): + """None (= unlimited) must cross the argv boundary as "0", never "None".""" + args = _build_aggregator_args( + socket_dir="/tmp/sockets", + pub_socket_name="pub", + metrics_socket_name="metrics", + metrics_output_dir=Path("/tmp/metrics"), + enable_streaming=False, + tokenizer_name=None, + drain_timeout_s=None, + tokenizer_workers=2, + early_stopping=False, + ) + idx = args.index("--drain-timeout") + assert args[idx + 1] == "0" + @pytest.mark.unit @pytest.mark.asyncio async def test_tokenizer_and_workers_forwarded_from_schema(self, tmp_path): """The benchmark forwards --tokenizer and --tokenizer-workers; the workers value comes from the schema default - (drain.metrics_tokenizer_workers), the single source of truth.""" + (settings.metrics_tokenizer_workers), the single source of truth.""" config = OfflineConfig(**_OFFLINE_KWARGS, settings=OfflineSettings()) ctx = self._make_ctx(config, tmp_path) ctx.tokenizer_name = "gpt2" @@ -1232,7 +1161,7 @@ async def _capture_launch(service_configs, *, timeout): idx = args.index("--tokenizer") assert args[idx + 1] == "gpt2" idx = args.index("--tokenizer-workers") - expected = str(config.settings.drain.metrics_tokenizer_workers) + expected = str(config.settings.metrics_tokenizer_workers) assert args[idx + 1] == expected @pytest.mark.unit @@ -1885,10 +1814,10 @@ def test_configured_drain_timeouts_propagate_to_phases( config = OfflineConfig( **_OFFLINE_KWARGS, settings=OfflineSettings( - drain=DrainConfig( - warmup_timeout_s=7.0, - performance_timeout_s=15.0, - accuracy_timeout_s=45.0, + timeouts=Timeouts( + warmup_drain_timeout_s=7.0, + performance_drain_timeout_s=15.0, + accuracy_drain_timeout_s=45.0, ), warmup=WarmupConfig(enabled=True, drain=True), ), @@ -2607,6 +2536,37 @@ def test_accuracy_only_normalizes_client_and_target_concurrency( assert ctx.config.settings.client.max_connections == 1 assert ctx.config.settings.load_pattern.target_concurrency == 1 + @pytest.mark.unit + def test_accuracy_only_setup_validates_with_non_default_api_type( + self, tmp_path, _base_patches, _simple_dataset, _rt_settings + ): + """A non-default endpoint api_type (propagated into the client via + _propagate_client_api_type's with_updates) must survive the ACC-mode + client normalization re-validation without errors.""" + config = OnlineConfig( + endpoint_config={"endpoints": ["http://x"], "api_type": "sglang"}, + model_params={"name": "test-model"}, + settings=OnlineSettings( + load_pattern=LoadPattern( + type=LoadPatternType.CONCURRENCY, target_concurrency=10 + ), + client=HTTPClientConfig( + num_workers=4, warmup_connections=0, max_connections=8 + ), + ), + report_dir=str(tmp_path), + ) + ctx = self._setup( + config, + TestMode.ACC, + (_simple_dataset, [], []), + _rt_settings, + ) + + assert ctx.config.settings.client.num_workers == 1 + assert ctx.config.endpoint_config.api_type == APIType.SGLANG + assert ctx.config.settings.client.api_type == APIType.SGLANG + @pytest.mark.unit def test_perf_run_leaves_target_concurrency_untouched( self, tmp_path, _base_patches, _simple_dataset, _rt_settings diff --git a/tests/unit/compliance/test_output_caching.py b/tests/unit/compliance/test_output_caching.py index ec2bc2af4..505bc6864 100644 --- a/tests/unit/compliance/test_output_caching.py +++ b/tests/unit/compliance/test_output_caching.py @@ -479,6 +479,7 @@ def test_refuses_result_on_incomplete_phase(self, tmp_path, monkeypatch): incomplete = MagicMock() incomplete.complete = False bench = MagicMock() + bench.run_timed_out = False bench.report = incomplete self._patch_phase(monkeypatch, num_samples=100, bench=bench) @@ -495,12 +496,30 @@ def test_interrupted_phase_raises_keyboard_interrupt(self, tmp_path, monkeypatch interrupted.state = "interrupted" interrupted.complete = False bench = MagicMock() + bench.run_timed_out = False bench.report = interrupted self._patch_phase(monkeypatch, num_samples=100, bench=bench) with pytest.raises(KeyboardInterrupt): run_audit(config, tmp_path) + @pytest.mark.unit + def test_run_timeout_raises_execution_error(self, tmp_path, monkeypatch): + """A whole-run watchdog (settings.timeouts.run_timeout_s) firing during + an audit phase must surface as ExecutionError naming the timeout, not + as the Ctrl-C KeyboardInterrupt path.""" + config = self._audit_config() + interrupted = MagicMock() + interrupted.state = "interrupted" + interrupted.complete = False + bench = MagicMock() + bench.run_timed_out = True + bench.report = interrupted + self._patch_phase(monkeypatch, num_samples=100, bench=bench) + + with pytest.raises(ExecutionError, match="run_timeout_s"): + run_audit(config, tmp_path) + @pytest.mark.unit def test_keyboard_interrupt_propagates(self, tmp_path, monkeypatch): """SIGINT during a phase surfaces as KeyboardInterrupt (exit 130), not a @@ -534,6 +553,7 @@ def test_strips_accuracy_datasets_from_phase_config(self, tmp_path, monkeypatch) config.datasets = [perf_ds, acc_ds] bench = MagicMock() + bench.run_timed_out = False bench.report = None # abort after the first phase's with_updates call self._patch_phase(monkeypatch, num_samples=100, bench=bench) @@ -566,6 +586,7 @@ def test_verify_zero_qps_raises_execution_error_not_bare_valueerror( report.qps = 0.0 report.n_samples_completed = 0 bench = MagicMock() + bench.run_timed_out = False bench.report = report self._patch_phase(monkeypatch, num_samples=100, bench=bench) @@ -589,6 +610,7 @@ def test_tmpfs_dir_removed_after_phase(self, tmp_path, monkeypatch): report.qps = 1.0 report.n_samples_completed = 4 bench = MagicMock() + bench.run_timed_out = False bench.report = report tmpfs_dir = tmp_path / "tmpfs" tmpfs_dir.mkdir() @@ -648,6 +670,7 @@ def test_acc_mode_phase_keeps_accuracy_datasets(self, tmp_path, monkeypatch): "inference_endpoint.commands.audit.setup_benchmark", setup_spy ) bench = MagicMock() + bench.run_timed_out = False bench.report = None # abort right after setup, before finalize matters bench.tmpfs_dir = Path("/nonexistent-tmpfs-path-for-tests") monkeypatch.setattr( diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py index 1e4b977aa..949670e59 100644 --- a/tests/unit/config/test_schema.py +++ b/tests/unit/config/test_schema.py @@ -353,32 +353,19 @@ def test_online_max_throughput_rejected(self): ) @pytest.mark.unit - def test_negative_min_duration_rejected(self): - with pytest.raises(ValueError, match="greater than or equal to 0"): + def test_max_duration_zero_rejected(self): + with pytest.raises(ValueError, match="greater than 0"): BenchmarkConfig( type=TestType.OFFLINE, model_params={"name": "M"}, endpoint_config={"endpoints": ["http://x"]}, datasets=[{"path": "D"}], - settings={"runtime": {"min_duration_ms": -1}}, - ) - - @pytest.mark.unit - def test_max_lt_min_duration_rejected(self): - with pytest.raises(ValueError, match="max_duration_ms"): - BenchmarkConfig( - type=TestType.OFFLINE, - model_params={"name": "M"}, - endpoint_config={"endpoints": ["http://x"]}, - datasets=[{"path": "D"}], - settings={ - "runtime": {"min_duration_ms": 5000, "max_duration_ms": 1000} - }, + settings={"runtime": {"max_duration_ms": 0}}, ) @pytest.mark.unit def test_max_duration_below_zero_rejected(self): - with pytest.raises(ValueError, match="greater than or equal to 0"): + with pytest.raises(ValueError, match="greater than 0"): BenchmarkConfig( type=TestType.OFFLINE, model_params={"name": "M"}, @@ -525,7 +512,7 @@ def test_redact_secret_fields_scrubs_url_credentials(self): assert redacted["description"] == value["description"] @pytest.mark.unit - def test_max_duration_zero_converts_to_none_in_runtime_settings(self): + def test_max_duration_defaults_to_none_in_runtime_settings(self): from inference_endpoint.config.runtime_settings import RuntimeSettings config = BenchmarkConfig( @@ -533,7 +520,6 @@ def test_max_duration_zero_converts_to_none_in_runtime_settings(self): model_params={"name": "M"}, endpoint_config={"endpoints": ["http://x"]}, datasets=[{"path": "D"}], - settings={"runtime": {"max_duration_ms": 0}}, ) rt = RuntimeSettings.from_config(config, dataloader_num_samples=100) assert rt.max_duration_ms is None @@ -900,7 +886,7 @@ class TestAgenticInferenceTotalSamples: """Tests for total_samples_to_issue() with agentic_inference load pattern.""" @pytest.mark.unit - def test_agentic_inference_uses_dataset_size_ignoring_duration(self): + def test_agentic_inference_uses_dataset_size(self): config = BenchmarkConfig( type=TestType.ONLINE, model_params={"name": "M"}, @@ -908,7 +894,6 @@ def test_agentic_inference_uses_dataset_size_ignoring_duration(self): datasets=[{"path": "D", "agentic_inference": {}}], settings={ "load_pattern": {"type": "agentic_inference", "target_concurrency": 4}, - "runtime": {"min_duration_ms": 600000}, }, ) rt = RuntimeSettings.from_config(config, dataloader_num_samples=4316) diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py new file mode 100644 index 000000000..0c61b6d6a --- /dev/null +++ b/tests/unit/config/test_timeouts.py @@ -0,0 +1,294 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the consolidated ``settings.timeouts`` block (Timeouts model), +the reworked ``runtime.max_duration_ms`` knob, and the hard removal of the +pre-consolidation config surface (``settings.drain``, top-level ``timeout``, +``settings.service_ready_timeout_s``, ``runtime.min_duration_ms``, and the +``settings.client.worker_*`` knobs).""" + +import random + +import pytest +import yaml +from inference_endpoint.config.runtime_settings import RuntimeSettings +from inference_endpoint.config.schema import ( + BenchmarkConfig, + LoadPattern, + LoadPatternType, + RuntimeConfig, + TestType, +) +from inference_endpoint.config.timeouts import Timeouts +from inference_endpoint.metrics.metric import Throughput +from pydantic import ValidationError + +_MINIMAL_KWARGS = { + "type": TestType.OFFLINE, + "model_params": {"name": "M"}, + "endpoint_config": {"endpoints": ["http://x"]}, + "datasets": [{"path": "D"}], +} + + +class TestTimeoutsDefaults: + @pytest.mark.unit + def test_defaults(self): + cfg = Timeouts() + assert cfg.run_timeout_s is None + assert cfg.service_ready_timeout_s == 30.0 + assert cfg.warmup_drain_timeout_s == 240.0 + assert cfg.performance_drain_timeout_s is None + assert cfg.accuracy_drain_timeout_s is None + assert cfg.metrics_drain_timeout_s is None + assert cfg.worker_initialization_timeout_s == 60.0 + assert cfg.worker_graceful_shutdown_wait_s == 0.5 + assert cfg.worker_force_kill_timeout_s == 0.5 + + @pytest.mark.unit + def test_mounted_on_settings_by_default(self): + config = BenchmarkConfig(**_MINIMAL_KWARGS) + assert config.settings.timeouts == Timeouts() + + @pytest.mark.unit + def test_metrics_tokenizer_workers_is_flat_settings_field(self): + config = BenchmarkConfig(**_MINIMAL_KWARGS) + assert config.settings.metrics_tokenizer_workers == 4 + + +class TestTimeoutsValidation: + @pytest.mark.unit + @pytest.mark.parametrize( + "field", + [ + "run_timeout_s", + "warmup_drain_timeout_s", + "performance_drain_timeout_s", + "accuracy_drain_timeout_s", + "metrics_drain_timeout_s", + ], + ) + @pytest.mark.parametrize("value", [0, -1.0]) + def test_deadline_must_be_positive_or_none(self, field, value): + # The 0-sentinel is dead: unlimited is spelled None, never 0. + with pytest.raises(ValidationError): + Timeouts(**{field: value}) + + @pytest.mark.unit + @pytest.mark.parametrize( + "field", + [ + "run_timeout_s", + "warmup_drain_timeout_s", + "performance_drain_timeout_s", + "accuracy_drain_timeout_s", + "metrics_drain_timeout_s", + ], + ) + def test_deadline_none_means_unlimited(self, field): + assert getattr(Timeouts(**{field: None}), field) is None + + @pytest.mark.unit + @pytest.mark.parametrize( + "field", + [ + "service_ready_timeout_s", + "worker_initialization_timeout_s", + "worker_graceful_shutdown_wait_s", + "worker_force_kill_timeout_s", + ], + ) + def test_ge_zero_fields_accept_zero_reject_negative(self, field): + assert getattr(Timeouts(**{field: 0}), field) == 0.0 + with pytest.raises(ValidationError): + Timeouts(**{field: -1.0}) + + @pytest.mark.unit + def test_extra_fields_rejected(self): + with pytest.raises(ValidationError): + Timeouts(unknown_field=1) + + +class TestDeletedConfigSurface: + """Hard cutover: the pre-consolidation keys must error, not silently pass.""" + + @pytest.mark.unit + def test_settings_drain_block_rejected(self): + with pytest.raises(ValidationError, match="drain"): + BenchmarkConfig( + **_MINIMAL_KWARGS, + settings={"drain": {"warmup_timeout_s": 10.0}}, + ) + + @pytest.mark.unit + def test_settings_service_ready_timeout_rejected(self): + with pytest.raises(ValidationError, match="service_ready_timeout_s"): + BenchmarkConfig( + **_MINIMAL_KWARGS, + settings={"service_ready_timeout_s": 10.0}, + ) + + @pytest.mark.unit + def test_runtime_min_duration_rejected(self): + with pytest.raises(ValidationError, match="min_duration_ms"): + BenchmarkConfig( + **_MINIMAL_KWARGS, + settings={"runtime": {"min_duration_ms": 1000}}, + ) + + @pytest.mark.unit + def test_top_level_timeout_rejected(self): + with pytest.raises(ValidationError, match="timeout"): + BenchmarkConfig(**_MINIMAL_KWARGS, timeout=42.0) + + @pytest.mark.unit + def test_client_worker_knob_rejected(self): + with pytest.raises(ValidationError, match="worker_initialization_timeout"): + BenchmarkConfig( + **_MINIMAL_KWARGS, + settings={"client": {"worker_initialization_timeout": 120.0}}, + ) + + @pytest.mark.unit + def test_client_worker_knob_rejected_from_yaml(self, tmp_path): + yaml_content = """ +type: "offline" +model_params: + name: "test-model" +endpoint_config: + endpoints: ["http://test:8000"] +datasets: + - path: "test.jsonl" +settings: + client: + worker_initialization_timeout: 120 +""" + config_file = tmp_path / "stale.yaml" + config_file.write_text(yaml_content) + with pytest.raises(ValidationError, match="worker_initialization_timeout"): + BenchmarkConfig.from_yaml_file(config_file) + + +class TestWorkerFieldsHiddenFromSerialization: + @pytest.mark.unit + def test_yaml_roundtrip_excludes_worker_carrier_fields(self, tmp_path): + """The runtime-carrier worker fields on the client never serialize, so + a persisted config reloads cleanly under extra=forbid.""" + config = BenchmarkConfig(**_MINIMAL_KWARGS) + out = tmp_path / "roundtrip.yaml" + config.to_yaml_file(out) + + dumped = yaml.safe_load(out.read_text()) + client_block = dumped.get("settings", {}).get("client", {}) or {} + carrier_fields = { + "worker_initialization_timeout_s", + "worker_graceful_shutdown_wait_s", + "worker_force_kill_timeout_s", + } + assert not carrier_fields & client_block.keys() + + loaded = BenchmarkConfig.from_yaml_file(out) + assert loaded.settings.timeouts == config.settings.timeouts + + +class TestTimeoutsYAMLRoundtrip: + @pytest.mark.unit + def test_yaml_block_loads(self, tmp_path): + yaml_content = """ +type: "offline" +model_params: + name: "test-model" +endpoint_config: + endpoints: ["http://test:8000"] +datasets: + - path: "test.jsonl" +settings: + timeouts: + run_timeout_s: 900 + warmup_drain_timeout_s: 12.5 + performance_drain_timeout_s: 30.0 + accuracy_drain_timeout_s: null + metrics_drain_timeout_s: 300.0 + worker_initialization_timeout_s: 90 +""" + config_file = tmp_path / "timeouts.yaml" + config_file.write_text(yaml_content) + config = BenchmarkConfig.from_yaml_file(config_file) + timeouts = config.settings.timeouts + assert timeouts.run_timeout_s == 900.0 + assert timeouts.warmup_drain_timeout_s == 12.5 + assert timeouts.performance_drain_timeout_s == 30.0 + assert timeouts.accuracy_drain_timeout_s is None + assert timeouts.metrics_drain_timeout_s == 300.0 + assert timeouts.worker_initialization_timeout_s == 90.0 + + +class TestMaxDurationSuffix: + """max_duration_ms keeps the duration suffix parser (600s, 10m, plain ms).""" + + @pytest.mark.unit + @pytest.mark.parametrize( + "value, expected_ms", + [ + ("600s", 600000), + ("10m", 600000), + ("600000ms", 600000), + ("600000", 600000), + (600000, 600000), + ("0.5m", 30000), + ("1.5s", 1500), + ], + ) + def test_suffix_parses(self, value, expected_ms): + cfg = RuntimeConfig(max_duration_ms=value) + assert cfg.max_duration_ms == expected_ms + + @pytest.mark.unit + def test_default_is_none(self): + assert RuntimeConfig().max_duration_ms is None + + @pytest.mark.unit + @pytest.mark.parametrize("value", [0, -1, "0s"]) + def test_zero_and_negative_rejected(self, value): + # No 0-sentinel: "no cap" is spelled None. + with pytest.raises(ValidationError): + RuntimeConfig(max_duration_ms=value) + + +class TestDatasetOnceDefault: + @pytest.mark.unit + def test_sample_count_defaults_to_dataset_size(self): + """Without n_samples_to_issue and without any duration knob, a run + issues the dataset exactly once.""" + config = BenchmarkConfig(**_MINIMAL_KWARGS) + rt = RuntimeSettings.from_config(config, dataloader_num_samples=123) + assert rt.n_samples_to_issue is None + assert rt.total_samples_to_issue() == 123 + + @pytest.mark.unit + def test_explicit_n_samples_still_wins(self): + rt = RuntimeSettings( + metric_target=Throughput(10.0), + reported_metrics=[Throughput(10.0)], + min_duration_ms=0, + max_duration_ms=None, + n_samples_from_dataset=123, + n_samples_to_issue=7, + min_sample_count=1, + rng_sched=random.Random(0), + rng_sample_index=random.Random(0), + load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), + ) + assert rt.total_samples_to_issue() == 7 diff --git a/tests/unit/config/test_yaml_loader.py b/tests/unit/config/test_yaml_loader.py index 0b0e8d473..d1d3d5181 100644 --- a/tests/unit/config/test_yaml_loader.py +++ b/tests/unit/config/test_yaml_loader.py @@ -45,13 +45,12 @@ def test_load_valid_yaml(self, tmp_path): path: "test.jsonl" settings: - runtime: - min_duration_ms: 60000 + timeouts: + worker_initialization_timeout_s: 120 load_pattern: type: "max_throughput" client: num_workers: 4 - worker_initialization_timeout: 120 transport: type: zmq recv_buffer_size: 16777216 @@ -68,7 +67,7 @@ def test_load_valid_yaml(self, tmp_path): assert config.name == "test-config" assert config.type == BenchmarkTestType.OFFLINE assert len(config.datasets) == 1 - assert config.settings.client.worker_initialization_timeout == 120.0 + assert config.settings.timeouts.worker_initialization_timeout_s == 120.0 assert config.settings.client.transport.recv_buffer_size == 16777216 assert config.settings.client.transport.send_buffer_size == 8388608 @@ -209,7 +208,7 @@ def test_create_default_offline_config(self): config = BenchmarkConfig.create_default_config(BenchmarkTestType.OFFLINE) assert isinstance(config, BenchmarkConfig) assert config.settings.load_pattern.type == LoadPatternType.MAX_THROUGHPUT - assert config.settings.runtime.min_duration_ms == 600000 + assert config.settings.timeouts.run_timeout_s is None assert config.settings.client.num_workers >= 1 # auto-resolved from -1 def test_create_default_online_config(self): @@ -217,7 +216,7 @@ def test_create_default_online_config(self): assert isinstance(config, BenchmarkConfig) assert config.settings.load_pattern.type == LoadPatternType.POISSON assert config.settings.load_pattern.target_qps == 10.0 - assert config.settings.runtime.min_duration_ms == 600000 + assert config.settings.timeouts.run_timeout_s is None def test_create_default_eval_not_implemented(self): with pytest.raises(CLIError, match="EVAL"): @@ -246,8 +245,8 @@ def test_serialize_deserialize_roundtrip(self, tmp_path): ) assert loaded.settings.load_pattern.type == original.settings.load_pattern.type assert ( - loaded.settings.client.worker_initialization_timeout - == original.settings.client.worker_initialization_timeout + loaded.settings.timeouts.worker_initialization_timeout_s + == original.settings.timeouts.worker_initialization_timeout_s ) assert ( loaded.settings.client.transport.recv_buffer_size diff --git a/tests/unit/scripts/test_metrics_preflight_tap.py b/tests/unit/scripts/test_metrics_preflight_tap.py new file mode 100644 index 000000000..9fdedbe69 --- /dev/null +++ b/tests/unit/scripts/test_metrics_preflight_tap.py @@ -0,0 +1,410 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Focused tests for the experiment-only GPT-OSS metrics preflight tap.""" + +# ruff: noqa: I001 +# Keep import layout stable across the pinned pre-commit and local uv ruff. + +from __future__ import annotations + +import csv +import importlib.util +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from inference_endpoint.async_utils.services.metrics_aggregator.snapshot import ( + CounterStat, + MetricsSnapshot, + MetricsSnapshotCodec, + SessionState, +) +from inference_endpoint.core.record import TOPIC_FRAME_SIZE + +pytestmark = pytest.mark.unit + + +def _load_tap(): + path = Path("scratchpad/gptoss_nvl144_pr334_vvv_20260728/metrics_preflight_tap.py") + spec = importlib.util.spec_from_file_location("metrics_preflight_tap", path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +tap = _load_tap() + + +def _write_process( + proc_root: Path, + *, + pid: int, + ppid: int, + argv: list[str], + status: str = "VmRSS:\t123 kB\nVmHWM:\t456 kB\n", +) -> None: + proc_dir = proc_root / str(pid) + proc_dir.mkdir() + (proc_dir / "stat").write_text(f"{pid} (command with spaces) S {ppid} 0 0\n") + (proc_dir / "cmdline").write_bytes(b"\0".join(a.encode() for a in argv) + b"\0") + (proc_dir / "status").write_text(status) + + +def _snapshot( + counter: int, + *, + state: SessionState = SessionState.LIVE, + pending: int = 0, + issued: int = 10, + completed: int = 5, +) -> MetricsSnapshot: + return MetricsSnapshot( + counter=counter, + timestamp_ns=counter * 100, + state=state, + n_pending_tasks=pending, + metrics=[ + CounterStat("total_samples_issued", issued), + CounterStat("total_samples_completed", completed), + CounterStat("total_samples_failed", 0), + ], + ) + + +class TestProcessDiscovery: + def test_finds_only_aggregator_below_root(self, tmp_path: Path) -> None: + proc = tmp_path / "proc" + proc.mkdir() + _write_process(proc, pid=100, ppid=1, argv=["benchmark"]) + _write_process(proc, pid=101, ppid=100, argv=["worker"]) + _write_process( + proc, + pid=102, + ppid=101, + argv=[ + "python", + "-m", + tap.AGGREGATOR_MODULE, + "--socket-dir", + "/dev/shm/zmq_a", + "--metrics-socket=metrics_a", + ], + ) + _write_process( + proc, + pid=200, + ppid=1, + argv=["python", "-m", tap.AGGREGATOR_MODULE], + ) + + found = tap.find_aggregator_descendant(100, proc) + + assert found is not None + assert found.pid == 102 + assert tap.parse_aggregator_socket_args(found.argv) == ( + "/dev/shm/zmq_a", + "metrics_a", + ) + assert ( + tap.metrics_ipc_address("/dev/shm/zmq_a", "metrics_a") + == "ipc:///dev/shm/zmq_a/metrics_a" + ) + + def test_missing_socket_arg_is_rejected(self) -> None: + with pytest.raises(ValueError, match="metrics-socket"): + tap.parse_aggregator_socket_args(["--socket-dir", "/tmp/x"]) + + +class TestSampling: + def test_reads_proc_cgroup_meminfo_and_tmpfs(self, tmp_path: Path) -> None: + proc = tmp_path / "proc" + proc.mkdir() + _write_process(proc, pid=42, ppid=1, argv=["aggregator"]) + (proc / "42" / "cgroup").write_text("0::/job/step\n") + (proc / "meminfo").write_text( + "MemTotal: 10000 kB\nMemAvailable: 2500 kB\n" + ) + + cgroup_root = tmp_path / "cgroup" + cgroup = cgroup_root / "job" / "step" + cgroup.mkdir(parents=True) + (cgroup / "memory.current").write_text("1000\n") + (cgroup / "memory.peak").write_text("2000\n") + (cgroup / "memory.max").write_text("3000\n") + (cgroup / "memory.events").write_text("oom 2\noom_kill 1\n") + + events = tmp_path / "benchmark_1" / "events" + events.mkdir(parents=True) + (events / "events.jsonl").write_bytes(b"x" * 17) + + location = tap._find_cgroup(42, proc, cgroup_root) + obs = tap.sample_memory( + 42, + location, + str(tmp_path / "benchmark_*" / "events" / "events.jsonl"), + proc, + ) + + assert obs == tap.MemoryObservation( + aggregator_alive=True, + rss_kib=123, + hwm_kib=456, + cgroup_current_bytes=1000, + cgroup_peak_bytes=2000, + cgroup_max_bytes=3000, + cgroup_oom=2, + cgroup_oom_kill=1, + mem_available_kib=2500, + mem_total_kib=10000, + tmpfs_event_files=1, + tmpfs_events_bytes=17, + ) + + +class TestSnapshotsAndArtifacts: + def test_decodes_frame_and_tracks_pending_memory_high_water( + self, tmp_path: Path + ) -> None: + codec = MetricsSnapshotCodec() + first = _snapshot(1, pending=3) + second = _snapshot( + 4, + state=SessionState.DRAINING, + pending=7, + issued=20, + completed=20, + ) + topic, payload = codec.encode(first) + assert len(topic) == TOPIC_FRAME_SIZE + assert tap.decode_metrics_frame(topic + payload, codec) == first + + stats = tap.MonitorStats( + started_wall_ns=100, + started_monotonic_ns=100, + root_pid=1, + aggregator_pid=42, + ) + stats.observe_snapshot(first, 1_000_000_000) + stats.observe_snapshot(second, 3_500_000_000) + stats.observe_memory( + tap.MemoryObservation( + aggregator_alive=True, + rss_kib=11, + hwm_kib=12, + cgroup_current_bytes=13, + cgroup_peak_bytes=14, + cgroup_max_bytes=15, + cgroup_oom=0, + cgroup_oom_kill=0, + mem_available_kib=16, + mem_total_kib=17, + tmpfs_event_files=1, + tmpfs_events_bytes=18, + ) + ) + + summary = stats.to_dict(ended_wall_ns=4_000_000_000, csv_path=tmp_path / "x") + assert summary["published_pending_high_water"] == 7 + assert summary["pending_at_first_draining"] == 7 + assert summary["counter_gap_total"] == 2 + assert summary["counter_gap_max"] == 2 + assert summary["max_snapshot_gap_s"] == 2.5 + assert summary["aggregator_rss_high_water_kib"] == 11 + assert summary["tmpfs_events_high_water_bytes"] == 18 + assert summary["telemetry_capture_valid"] is True + assert summary["telemetry_capture_failures"] == [] + + def test_capture_gate_requires_rss_and_oom_counters(self) -> None: + stats = tap.MonitorStats( + started_wall_ns=100, + started_monotonic_ns=100, + root_pid=1, + aggregator_pid=42, + cgroup_version=2, + snapshots_received=2, + published_pending_high_water=7, + aggregator_reported_hwm_high_water_kib=12, + cgroup_memory_current_high_water_bytes=13, + cgroup_memory_peak_high_water_bytes=14, + ) + + assert tap.telemetry_capture_failures(stats) == [ + "aggregator_rss_missing", + "cgroup_oom_missing", + "cgroup_oom_kill_missing", + ] + + stats.cgroup_version = 1 + assert tap.telemetry_capture_failures(stats) == [ + "aggregator_rss_missing", + "cgroup_oom_missing", + ] + + def test_csv_finalization_and_atomic_summary(self, tmp_path: Path) -> None: + csv_path = tmp_path / "telemetry.csv" + artifact = tap.AtomicCsv(csv_path, fsync_interval_s=0) + artifact.open() + row = dict.fromkeys(tap.CSV_FIELDS, "") + row["row_kind"] = "memory" + artifact.write(row) + artifact.finalize() + + with csv_path.open(newline="") as f: + rows = list(csv.DictReader(f)) + assert len(rows) == 1 + assert rows[0]["row_kind"] == "memory" + assert not csv_path.with_suffix(".csv.part").exists() + + summary_path = tmp_path / "summary.json" + payload = {"status": "complete"} + from inference_endpoint.utils.atomic_write import atomic_write_bytes + + atomic_write_bytes( + summary_path, (json.dumps(payload, sort_keys=True) + "\n").encode() + ) + assert json.loads(summary_path.read_text()) == payload + + def test_missing_aggregator_is_nonzero_and_still_atomic( + self, tmp_path: Path + ) -> None: + csv_path = tmp_path / "telemetry.csv" + summary_path = tmp_path / "summary.json" + args = tap._build_parser().parse_args( + [ + "--root-pid", + str(2**31 - 1), + "--csv", + str(csv_path), + "--summary", + str(summary_path), + "--discover-timeout-s", + "0", + ] + ) + + exit_code, summary = tap.run(args) + + assert exit_code == 2 + assert summary["status"] == "aggregator_not_found" + assert summary["telemetry_capture_valid"] is False + assert "aggregator_not_found" in summary["telemetry_capture_failures"] + assert csv_path.is_file() + assert summary_path.is_file() + assert not csv_path.with_suffix(".csv.part").exists() + + def test_end_to_end_discovers_and_taps_metrics_pub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + socket_dir = tmp_path / "sockets" + socket_dir.mkdir() + socket_name = "metrics_test" + child_code = """ +import sys +import time +import zmq +from inference_endpoint.async_utils.services.metrics_aggregator.snapshot import ( + CounterStat, MetricsSnapshot, MetricsSnapshotCodec, SessionState, +) + +args = sys.argv[1:] +socket_dir = args[args.index("--socket-dir") + 1] +socket_name = args[args.index("--metrics-socket") + 1] +ctx = zmq.Context() +sock = ctx.socket(zmq.PUB) +sock.setsockopt(zmq.LINGER, 0) +sock.bind(f"ipc://{socket_dir}/{socket_name}") +codec = MetricsSnapshotCodec() +time.sleep(0.5) +for i in range(1, 7): + snap = MetricsSnapshot( + counter=i, + timestamp_ns=i, + state=SessionState.LIVE, + n_pending_tasks=i, + metrics=[ + CounterStat("total_samples_issued", i), + CounterStat("total_samples_completed", i), + CounterStat("total_samples_failed", 0), + ], + ) + topic, payload = codec.encode(snap) + sock.send(topic + payload) + time.sleep(0.15) +sock.close(0) +ctx.term() +""" + child = subprocess.Popen( + [ + sys.executable, + "-c", + child_code, + tap.AGGREGATOR_MODULE, + "--socket-dir", + str(socket_dir), + "--metrics-socket", + socket_name, + ] + ) + observation = tap.MemoryObservation( + aggregator_alive=True, + rss_kib=100, + hwm_kib=200, + cgroup_current_bytes=300, + cgroup_peak_bytes=400, + cgroup_max_bytes=500, + cgroup_oom=0, + cgroup_oom_kill=0, + mem_available_kib=600, + mem_total_kib=700, + tmpfs_event_files=0, + tmpfs_events_bytes=0, + ) + monkeypatch.setattr(tap, "sample_memory", lambda *args, **kwargs: observation) + csv_path = tmp_path / "telemetry.csv" + summary_path = tmp_path / "summary.json" + args = tap._build_parser().parse_args( + [ + "--root-pid", + str(os.getpid()), + "--csv", + str(csv_path), + "--summary", + str(summary_path), + "--discover-timeout-s", + "5", + "--discover-poll-s", + "0.02", + "--sample-interval-s", + "0.05", + "--poll-timeout-ms", + "20", + "--post-aggregator-exit-s", + "0.1", + "--fsync-interval-s", + "0", + ] + ) + try: + exit_code, summary = tap.run(args) + finally: + child.wait(timeout=5) + + assert exit_code == 0 + assert summary["status"] == "aggregator_exited" + assert summary["telemetry_capture_valid"] is True + assert summary["snapshots_received"] >= 2 + assert summary["published_pending_high_water"] >= 2 + assert summary["aggregator_reported_hwm_high_water_kib"] == 200 + assert summary["cgroup_memory_current_high_water_bytes"] == 300 + assert summary["cgroup_memory_peak_high_water_bytes"] == 400 + assert csv_path.is_file() + assert summary_path.is_file() + with csv_path.open(newline="") as handle: + rows = list(csv.DictReader(handle)) + assert rows[-1]["row_kind"] == "terminal_memory" From 6dcc4fd94b5dcc83431f10a6c8f16bce0fa9b9a6 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 13 Aug 2026 13:11:52 -0700 Subject: [PATCH 02/45] feat(metrics): fail the run when the metrics drain times out with pending tokenization An expired metrics_drain_timeout_s finalizes the aggregator as COMPLETE with n_pending_tasks > 0; previously that exited 0 with complete: false buried in result_summary.json. run_benchmark now raises ExecutionError after the artifacts are written, so partial ISL/OSL/TPOT stats can never look like a clean run. (The audit path already refused to certify these.) --- AGENTS.md | 2 +- docs/CLI_QUICK_REFERENCE.md | 24 +++++----- .../commands/benchmark/execute.py | 17 +++++++ .../templates/concurrency_template_full.yaml | 2 +- .../templates/offline_template_full.yaml | 2 +- .../templates/online_template_full.yaml | 2 +- src/inference_endpoint/config/timeouts.py | 5 +- .../integration/commands/test_run_timeout.py | 47 +++++++++++++++++++ 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4c41fde22..e2432545d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. - **Series storage**: each `SeriesSampler` keeps three parallel views: O(1) cheap rollups (count/total/min/max/sum_sq, exact), an HDR Histogram (cheap live percentiles), and an in-memory `array.array` of raw values (for exact percentiles in the `COMPLETE` snapshot). Hot path is `registry.record(name, value)` — no allocation, no I/O. - **Counter API**: `registry.increment(name, delta=1)` for sample-event counters. `registry.set_counter(name, value)` only for the three derived-duration counters (`total_duration_ns` max-of-elapsed, `tracked_duration_ns` sum-of-blocks, `legacy_loadgen_window_duration_ns` first-issue→last-issued-completion span for LoadGen-parity QPS/TPS). -- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget — argv 0 = unlimited; schema `settings.timeouts.metrics_drain_timeout_s` uses None = unlimited, converted at the argv boundary) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0`; interrupted runs are detected as `state == INTERRUPTED` directly. +- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget — argv 0 = unlimited; schema `settings.timeouts.metrics_drain_timeout_s` uses None = unlimited, converted at the argv boundary) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. - **Final delivery is dual-path with separated concerns**: `publish_final` atomically writes `final_snapshot.json` (`tmp + fsync(file) + rename + fsync(parent_dir)`) — this is the **primary** Report source — AND emits the terminal-state snapshot over pub/sub as a TUI shutdown signal. Each path is wrapped in its own try/except so one failure cannot suppress the other. Main process consumer reads `final_snapshot.json` (via `json.loads` to dict, no Struct decode); falls back to the subscriber's `latest` live snapshot only if the file is missing (e.g. SIGKILL / OOM before the signal handler ran). The dict form is the canonical consumer contract (see `snapshot_to_dict`). - **Early stopping (on by default)**: series registered with `register_series(..., tail_latency=True)` (today ttft/tpot/latency) get MLPerf early-stopping percentile estimates on the COMPLETE (exact) snapshot — a compact `early_stopping_percentiles` map in `result_summary.json` whose keys mirror the `percentiles` grid (≥ p50) with estimate-or-`null` values; rich detail is INFO-logged. On by default (cold-path only; the exact path shares one in-place sort between the percentile grid and the estimates); `settings.early_stopping.enabled: false` / `--no-early-stopping` opts out. Confidence/tolerance are LoadGen constants. Pure math in `metrics/early_stopping.py`; post-hoc recomputation from any run's `events.jsonl` via `scripts/early_stopping_estimate_from_events.py`. See docs/early_stopping.md. - **Histogram bucket edges are dynamic per snapshot**: log-spaced over the observed `[min, max]`. Bucket count is fixed at construction; consumers MUST re-render from the snapshot's `(lo, hi, count)` triples each frame and MUST NOT track bucket-by-index across snapshots. diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 6f69d7b6b..c1d55dc5f 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -122,18 +122,18 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. All give-up deadlines live under `settings.timeouts`; the only workload duration is `settings.runtime.max_duration_ms`. `null`/unset means "wait indefinitely" (or "off") everywhere. -| YAML path | CLI flag | Semantics | -| --------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps the performance phase (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid | -| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog; firing aborts the entire run — report marked INTERRUPTED, non-zero exit | -| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | -| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | -| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely) | -| `settings.timeouts.worker_initialization_timeout_s` | `--worker-initialization-timeout-s` | Wait for endpoint-client worker processes to start (default 60) | -| `settings.timeouts.worker_graceful_shutdown_wait_s` | `--worker-graceful-shutdown-wait-s` | Post-run wait for workers to exit gracefully (default 0.5) | -| `settings.timeouts.worker_force_kill_timeout_s` | `--worker-force-kill-timeout-s` | Wait after SIGTERM before SIGKILL during worker teardown (default 0.5) | +| YAML path | CLI flag | Semantics | +| --------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps the performance phase (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid | +| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog; firing aborts the entire run — report marked INTERRUPTED, non-zero exit | +| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | +| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | +| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | +| `settings.timeouts.worker_initialization_timeout_s` | `--worker-initialization-timeout-s` | Wait for endpoint-client worker processes to start (default 60) | +| `settings.timeouts.worker_graceful_shutdown_wait_s` | `--worker-graceful-shutdown-wait-s` | Post-run wait for workers to exit gracefully (default 0.5) | +| `settings.timeouts.worker_force_kill_timeout_s` | `--worker-force-kill-timeout-s` | Wait after SIGTERM before SIGKILL during worker teardown (default 0.5) | How the knobs compose: diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index edef04523..8f079a54f 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -1312,6 +1312,23 @@ def run_benchmark( f"Run timeout ({run_timeout_s}s) reached; run aborted and " "report marked INTERRUPTED" ) + if ( + bench.report is not None + and bench.report.state == "complete" + and not bench.report.complete + ): + # The aggregator gave up on its tokenization backlog when + # metrics_drain_timeout_s expired (state "complete" with pending + # tasks). The artifacts above are already written with + # complete: false; fail loudly instead of exiting 0 on partial + # ISL/OSL/TPOT stats. + raise ExecutionError( + "Metrics drain timed out " + f"(metrics_drain_timeout_s=" + f"{config.settings.timeouts.metrics_drain_timeout_s}): " + "tokenization did not finish before the deadline; report is " + "partial (complete: false in result_summary.json)" + ) except KeyboardInterrupt: # Salvage results (finally), then propagate to main.py -> exit 130. logger.warning("Benchmark interrupted by user") diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index d2c3efafe..97a13f6be 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -86,7 +86,7 @@ settings: warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) - metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index 7250f2ca5..d232f702f 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -86,7 +86,7 @@ settings: warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) - metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 8b0d18f39..aad246771 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -87,7 +87,7 @@ settings: warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) - metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain is surfaced via n_pending_tasks > 0, never silently dropped. + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) diff --git a/src/inference_endpoint/config/timeouts.py b/src/inference_endpoint/config/timeouts.py index c767ad445..abd22c3f8 100644 --- a/src/inference_endpoint/config/timeouts.py +++ b/src/inference_endpoint/config/timeouts.py @@ -126,8 +126,9 @@ class Timeouts(WithUpdatesMixin, BaseModel): gt=0, description=( "Wall-clock budget (seconds) to finish tokenizing buffered samples " - "after ENDED (None = wait indefinitely). An incomplete drain is " - "surfaced via n_pending_tasks > 0, never silently dropped." + "after ENDED (None = wait indefinitely). An incomplete drain fails " + "the run: artifacts are written with complete: false, then " + "run_benchmark exits non-zero." ), ) worker_initialization_timeout_s: float = Field( diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index e4d811b01..96835c0c4 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -170,3 +170,50 @@ def test_run_timeout_during_metrics_drain_interrupts(mock_http_echo_server, tmp_ snapshot = _read_final_snapshot(report_dir) assert snapshot["state"] == "interrupted" + + +@pytest.mark.integration +def test_metrics_drain_timeout_fails_run(mock_http_echo_server, tmp_path): + """An expired metrics_drain_timeout_s fails the run instead of exiting 0. + + The aggregator finalizes as COMPLETE with a pending tokenization backlog + (state "complete", n_pending_tasks > 0). Artifacts must still be written + with complete: false, and run_benchmark must raise so partial ISL/OSL + stats can never look like a clean exit. + """ + dataset_path = tmp_path / "big_prompts.jsonl" + prompt = "lorem ipsum " * 21_000 # ~250 KB per sample + with dataset_path.open("w") as f: + for i in range(100): + f.write(json.dumps({"prompt": f"{i} {prompt}"}) + "\n") + + report_dir = tmp_path / "report" + config = BenchmarkConfig( + type=TestType.OFFLINE, + endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), + model_params=ModelParams( + name=str(_CHAR_TOKENIZER_DIR), streaming=StreamingMode.OFF + ), + datasets=[Dataset(path=str(dataset_path), type=DatasetType.PERFORMANCE)], + report_dir=report_dir, + settings=Settings( + load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), + client=_FAST_CLIENT, + # Defer every ISL/OSL tokenization to the end-of-run drain, then + # give the drain a budget far below the ~50M-char backlog. No run + # watchdog: the drain deadline itself must fail the run. + metrics_tokenizer_workers=0, + timeouts=Timeouts(metrics_drain_timeout_s=1.0), + warmup=WarmupConfig(enabled=False), + ), + ) + + with pytest.raises(ExecutionError, match="Metrics drain timed out"): + run_benchmark(config, TestMode.PERF) + + snapshot = _read_final_snapshot(report_dir) + assert snapshot["state"] == "complete" + assert snapshot["n_pending_tasks"] > 0 + + summary = _read_result_summary(report_dir) + assert summary["complete"] is False From cf30113efd176a17667de1b6364444e39682a840 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 13 Aug 2026 19:45:56 -0700 Subject: [PATCH 03/45] refactor(config): dissolve enums.py into its owning domain modules Every enum had exactly one consumer module, so the kind-based enums.py bought no cycle-breaking and no sharing: LoadPatternType/ProfilerEngine now live in settings.py beside LoadPattern/ProfilingConfig, OSLDistributionType/StreamingMode in model_params.py, DatasetType/EvalMethod/ScorerMethod in datasets.py, and the root-level TestType/TestMode in schema.py. The split criterion is now uniform: one module per config domain, every name beside its owner, schema.py = root aggregate + cross-domain validation + re-export hub (import sites unchanged). --- .pre-commit-config.yaml | 2 +- AGENTS.md | 5 +- src/inference_endpoint/config/datasets.py | 32 ++++- src/inference_endpoint/config/enums.py | 131 ------------------ src/inference_endpoint/config/model_params.py | 24 +++- src/inference_endpoint/config/schema.py | 55 ++++++-- src/inference_endpoint/config/settings.py | 27 +++- 7 files changed, 126 insertions(+), 150 deletions(-) delete mode 100644 src/inference_endpoint/config/enums.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a111ddbf0..6d331509c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: entry: uv run --no-sync python scripts/regenerate_templates.py language: system pass_filenames: false - files: ^(src/inference_endpoint/config/((schema|enums|audit|model_params|datasets|settings|timeouts)\.py|templates/.*)|src/inference_endpoint/endpoint_client/config\.py|scripts/regenerate_templates\.py)$ + files: ^(src/inference_endpoint/config/((schema|audit|model_params|datasets|settings|timeouts)\.py|templates/.*)|src/inference_endpoint/endpoint_client/config\.py|scripts/regenerate_templates\.py)$ - id: add-license-header name: Add license headers diff --git a/AGENTS.md b/AGENTS.md index e2432545d..d9ceeec06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Dataset Manager --> Load Generator --> Endpoint Client --> External Endpoint | **Dataset Manager** | `src/inference_endpoint/dataset_manager/` | Loads JSONL, HuggingFace, CSV, JSON, Parquet datasets. `Dataset` base class with `load_sample()`/`num_samples()` interface | | **Metrics Aggregator** | `src/inference_endpoint/async_utils/services/metrics_aggregator/` | Subprocess. Subscribes to events, aggregates per-sample metrics into a `MetricsRegistry` (counters + HDR-histogram series + raw values), publishes `MetricsSnapshot` over IPC PUB at a configurable cadence (`SessionState`: `INITIALIZE` → `LIVE` → `DRAINING` → {`COMPLETE` \| `INTERRUPTED`}). Final snapshot is atomically written to `final_snapshot.json` as the **primary** Report source; the terminal pub/sub frame is a TUI "run finished" signal only. | | **Report** | `src/inference_endpoint/metrics/report.py` | `Report.from_snapshot(dict)` — pure-function builder consuming the dict form (`snapshot_to_dict`). Reads `final_snapshot.json` directly via `json.loads` (no Struct decode). Plumbs `complete = (state == "complete" and n_pending_tasks == 0)`; renders an explicit warning for `INTERRUPTED` runs. | -| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema split into focused modules (`schema.py` = BenchmarkConfig + EndpointConfig + re-export hub; `enums.py`, `audit.py`, `model_params.py`, `datasets.py`, `settings.py`), `Timeouts` (`config/timeouts.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | +| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema split into focused modules (`schema.py` = BenchmarkConfig + EndpointConfig + root TestType/TestMode + re-export hub; `audit.py`, `model_params.py`, `datasets.py`, `settings.py` — each domain owns its models AND enums), `Timeouts` (`config/timeouts.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | | **CLI** | `src/inference_endpoint/main.py`, `commands/benchmark/cli.py` | cyclopts-based, auto-generated from `schema.py` and `HTTPClientConfig` Pydantic models. Flat shorthands via `cyclopts.Parameter(alias=...)` | | **Async Utils** | `src/inference_endpoint/async_utils/` | `LoopManager` (uvloop + eager_task_factory), ZMQ transport layer, generic `MessageCodec[T]`-parametrized pub/sub, event publisher | | **OpenAI/SGLang** | `src/inference_endpoint/openai/`, `sglang/` | Protocol adapters and response accumulators for different API formats. `openai_completions` adapter (`completions_adapter.py`) sends pre-tokenized token IDs to `/v1/completions`, bypassing the server chat template — required for gpt-oss-120b on vLLM. `sglang` adapter sends to `/generate` via `input_ids`. Both apply `Harmonize()` client-side. | @@ -244,8 +244,7 @@ src/inference_endpoint/ │ ├── early_stopping.py # MLPerf LoadGen early-stopping percentile estimates (pure math; see docs/early_stopping.md) │ └── results_plots.py # Standardized run-artifact plots (matplotlib-guarded); CLI: scripts/plot_results.py ├── config/ -│ ├── schema.py # BenchmarkConfig + EndpointConfig; re-export hub for the schema surface -│ ├── enums.py # Shared schema enums (TestType, LoadPatternType, StreamingMode, ...) +│ ├── schema.py # BenchmarkConfig + EndpointConfig + TestType/TestMode; re-export hub for the schema surface │ ├── audit.py # Audit config models (audit: YAML block) │ ├── model_params.py # ModelParams, OSLDistribution, SubmissionReference │ ├── datasets.py # Dataset, AccuracyConfig, AgenticInferenceConfig diff --git a/src/inference_endpoint/config/datasets.py b/src/inference_endpoint/config/datasets.py index 06fcd4d0a..da368910d 100644 --- a/src/inference_endpoint/config/datasets.py +++ b/src/inference_endpoint/config/datasets.py @@ -23,13 +23,13 @@ from __future__ import annotations +from enum import Enum from pathlib import Path from typing import Annotated, Any, Self import cyclopts from pydantic import BaseModel, ConfigDict, Field, model_validator -from .enums import DatasetType, EvalMethod, ScorerMethod from .model_params import ModelParams @@ -57,6 +57,36 @@ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any _METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"}) +class DatasetType(str, Enum): + """Dataset purpose type.""" + + PERFORMANCE = "performance" + ACCURACY = "accuracy" + + +class EvalMethod(str, Enum): + """Evaluation methods for accuracy testing.""" + + EXACT_MATCH = "exact_match" + CONTAINS = "contains" + JUDGE = "judge" + + +class ScorerMethod(str, Enum): + """Registered scorer methods for accuracy evaluation.""" + + PASS_AT_1 = "pass_at_1" + STRING_MATCH = "string_match" + ROUGE = "rouge" + CODE_BENCH = "code_bench_scorer" + SHOPIFY_CATEGORY_F1 = "shopify_category_f1" + AGENTIC_INFERENCE_INLINE = "agentic_inference_inline" + VBENCH = "vbench" + BFCL_V4 = "bfcl_v4" + LEGACY_MLPERF_DEEPSEEK_R1 = "legacy_mlperf_deepseek_r1" + SWE_BENCH = "swe_bench_scorer" + + class AgenticInferenceConfig(BaseModel): """Agentic inference conversation configuration. diff --git a/src/inference_endpoint/config/enums.py b/src/inference_endpoint/config/enums.py deleted file mode 100644 index e48c70e85..000000000 --- a/src/inference_endpoint/config/enums.py +++ /dev/null @@ -1,131 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Configuration enums. - -Split criterion: one module per config domain; enums shared across the config -models live here so every sibling module can import them without cycles. -``config/schema.py`` re-exports the public surface. -""" - -from __future__ import annotations - -from enum import Enum - - -class LoadPatternType(str, Enum): - """Load pattern types.""" - - MAX_THROUGHPUT = "max_throughput" # Offline: all queries at t=0 - POISSON = "poisson" # Online: fixed QPS with Poisson distribution - CONCURRENCY = "concurrency" # Online: fixed concurrent requests - AGENTIC_INFERENCE = ( - "agentic_inference" # Agentic inference conversations with turn sequencing - ) - BURST = "burst" # Burst pattern (TODO) - STEP = "step" # Step pattern (TODO) - - -class OSLDistributionType(str, Enum): - """Output Sequence Length distribution types.""" - - ORIGINAL = "original" # Use original distribution from dataset (default) - FIXED = "fixed" # Fixed length for all outputs - UNIFORM = "uniform" # Uniform distribution between min and max - NORMAL = "normal" # Normal/Gaussian distribution - - -class DatasetType(str, Enum): - """Dataset purpose type.""" - - PERFORMANCE = "performance" - ACCURACY = "accuracy" - - -class EvalMethod(str, Enum): - """Evaluation methods for accuracy testing.""" - - EXACT_MATCH = "exact_match" - CONTAINS = "contains" - JUDGE = "judge" - - -class ScorerMethod(str, Enum): - """Registered scorer methods for accuracy evaluation.""" - - PASS_AT_1 = "pass_at_1" - STRING_MATCH = "string_match" - ROUGE = "rouge" - CODE_BENCH = "code_bench_scorer" - SHOPIFY_CATEGORY_F1 = "shopify_category_f1" - AGENTIC_INFERENCE_INLINE = "agentic_inference_inline" - VBENCH = "vbench" - BFCL_V4 = "bfcl_v4" - LEGACY_MLPERF_DEEPSEEK_R1 = "legacy_mlperf_deepseek_r1" - SWE_BENCH = "swe_bench_scorer" - - -class TestMode(str, Enum): - """Test mode controlling performance issuance and response collection. - - - PERF: Run performance and ordinary configured scoring without in-process - collection; skip scorers that own an external evaluation run - - ACC: Skip performance and collect responses for configured scoring - - BOTH: Run performance and configured scoring with response collection - """ - - PERF = "perf" - ACC = "acc" - BOTH = "both" - - -class StreamingMode(str, Enum): - """Streaming mode for response handling. - - - AUTO: Automatically enable for online mode, disable for offline mode - - ON: Force streaming enabled (for TTFT metrics) - - OFF: Force streaming disabled - """ - - AUTO = "auto" - ON = "on" - OFF = "off" - - -class TestType(str, Enum): - """Test type for both config classification and execution mode. - - - OFFLINE: Max throughput benchmark (all queries at t=0) - - ONLINE: Sustained QPS benchmark (Poisson or concurrency-based) - - EVAL: Accuracy evaluation - - SUBMISSION: Official submission (may include both perf and accuracy) - """ - - OFFLINE = "offline" - ONLINE = "online" - EVAL = "eval" - SUBMISSION = "submission" - - -class ProfilerEngine(str, Enum): - """Inference engine whose profiling protocol the client should drive. - - Selects the HTTP path layout used to derive start/stop URLs from - ``endpoint_config.endpoints``. Each value corresponds to one server-side - profiling protocol; add a new variant + ``_PROFILE_PATHS`` row to support - another engine. - """ - - VLLM = "vllm" diff --git a/src/inference_endpoint/config/model_params.py b/src/inference_endpoint/config/model_params.py index a823039d7..1e47e3de3 100644 --- a/src/inference_endpoint/config/model_params.py +++ b/src/inference_endpoint/config/model_params.py @@ -22,12 +22,12 @@ from __future__ import annotations +from enum import Enum from typing import Annotated, Any, Self import cyclopts from pydantic import BaseModel, ConfigDict, Field, model_validator -from .enums import OSLDistributionType, StreamingMode from .ruleset_base import BenchmarkSuiteRuleset @@ -46,6 +46,28 @@ def _non_default_completion_controls(mp: ModelParams) -> list[str]: return [name for name, non_default in checks.items() if non_default] +class OSLDistributionType(str, Enum): + """Output Sequence Length distribution types.""" + + ORIGINAL = "original" # Use original distribution from dataset (default) + FIXED = "fixed" # Fixed length for all outputs + UNIFORM = "uniform" # Uniform distribution between min and max + NORMAL = "normal" # Normal/Gaussian distribution + + +class StreamingMode(str, Enum): + """Streaming mode for response handling. + + - AUTO: Automatically enable for online mode, disable for offline mode + - ON: Force streaming enabled (for TTFT metrics) + - OFF: Force streaming disabled + """ + + AUTO = "auto" + ON = "on" + OFF = "off" + + class OSLDistribution(BaseModel): """Output Sequence Length distribution configuration. diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index c809e0f8b..2c1046eb7 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -20,16 +20,19 @@ cyclopts.Parameter(alias=...) on Annotated fields to declare shorthand aliases alongside dotted paths. -Split criterion: one module per config domain (enums / audit / model_params / -datasets / settings / timeouts); this module owns only the root aggregate -(``BenchmarkConfig`` and its cross-field validation) plus the explicit -re-export hub, so every existing ``config.schema`` import site keeps working. +Split criterion: one module per config domain (audit / model_params / +datasets / settings / timeouts), with every name — model, enum, helper — +living beside its owner; this module owns only the root aggregate +(``BenchmarkConfig``, its cross-field validation, and the root-level +``TestType``/``TestMode`` enums) plus the explicit re-export hub, so every +existing ``config.schema`` import site keeps working. """ from __future__ import annotations import logging from collections import Counter +from enum import Enum from pathlib import Path from typing import Annotated, Any, Literal, Self, Union from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -51,29 +54,29 @@ from ..exceptions import CLIError from ..utils import WithUpdatesMixin from .audit import AuditConfig, AuditTestId, OutputCachingTestConfig -from .datasets import AccuracyConfig, AgenticInferenceConfig, Dataset -from .enums import ( +from .datasets import ( + AccuracyConfig, + AgenticInferenceConfig, + Dataset, DatasetType, EvalMethod, - LoadPatternType, - OSLDistributionType, - ProfilerEngine, ScorerMethod, - StreamingMode, - TestMode, - TestType, ) from .model_params import ( ModelParams, OSLDistribution, + OSLDistributionType, + StreamingMode, SubmissionReference, _non_default_completion_controls, ) from .settings import ( EarlyStoppingConfig, LoadPattern, + LoadPatternType, OfflineSettings, OnlineSettings, + ProfilerEngine, ProfilingConfig, RuntimeConfig, Settings, @@ -120,6 +123,34 @@ logger = logging.getLogger(__name__) +class TestMode(str, Enum): + """Test mode controlling performance issuance and response collection. + + - PERF: Run performance and ordinary configured scoring without in-process + collection; skip scorers that own an external evaluation run + - ACC: Skip performance and collect responses for configured scoring + - BOTH: Run performance and configured scoring with response collection + """ + + PERF = "perf" + ACC = "acc" + BOTH = "both" + + +class TestType(str, Enum): + """Test type for both config classification and execution mode. + + - OFFLINE: Max throughput benchmark (all queries at t=0) + - ONLINE: Sustained QPS benchmark (Poisson or concurrency-based) + - EVAL: Accuracy evaluation + - SUBMISSION: Official submission (may include both perf and accuracy) + """ + + OFFLINE = "offline" + ONLINE = "online" + EVAL = "eval" + SUBMISSION = "submission" + class EndpointConfig(BaseModel): diff --git a/src/inference_endpoint/config/settings.py b/src/inference_endpoint/config/settings.py index f90893859..78062be7d 100644 --- a/src/inference_endpoint/config/settings.py +++ b/src/inference_endpoint/config/settings.py @@ -22,6 +22,7 @@ from __future__ import annotations +from enum import Enum from typing import Annotated, Any, Self import cyclopts @@ -36,10 +37,22 @@ ) from ..endpoint_client.config import HTTPClientConfig -from .enums import LoadPatternType, ProfilerEngine from .timeouts import Timeouts +class LoadPatternType(str, Enum): + """Load pattern types.""" + + MAX_THROUGHPUT = "max_throughput" # Offline: all queries at t=0 + POISSON = "poisson" # Online: fixed QPS with Poisson distribution + CONCURRENCY = "concurrency" # Online: fixed concurrent requests + AGENTIC_INFERENCE = ( + "agentic_inference" # Agentic inference conversations with turn sequencing + ) + BURST = "burst" # Burst pattern (TODO) + STEP = "step" # Step pattern (TODO) + + class RuntimeConfig(BaseModel): """Runtime configuration. @@ -212,6 +225,18 @@ class WarmupConfig(BaseModel): ] = Field(42, description="RNG seed for warmup scheduling and sample ordering") +class ProfilerEngine(str, Enum): + """Inference engine whose profiling protocol the client should drive. + + Selects the HTTP path layout used to derive start/stop URLs from + ``endpoint_config.endpoints``. Each value corresponds to one server-side + profiling protocol; add a new variant + ``_PROFILE_PATHS`` row to support + another engine. + """ + + VLLM = "vllm" + + @cyclopts.Parameter(name="*") class ProfilingConfig(BaseModel): """Client-side trigger for the server's profiler. From 7cef9c9e32e7b6e23af0ae80ac8f371b900ff55a Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 13 Aug 2026 23:53:53 -0700 Subject: [PATCH 04/45] docs(examples): drop default-restating timeout keys from qwen3-vl example Examples set only non-default overrides; the null drain deadlines equal the schema defaults. --- .../offline_qwen3_vl_235b_a22b_shopify.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml index 5b2660af9..3e393480d 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml @@ -38,8 +38,6 @@ settings: timeouts: # Increase for slow worker startup (spawn, imports). Default 60s may be too short. worker_initialization_timeout_s: 120 - performance_drain_timeout_s: null # Performance drain timeout in seconds (null = wait indefinitely) - accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (null = wait indefinitely) warmup: enabled: true # Enable warmup phase before performance run n_requests: 1600 # Warmup request count (None = full dataset once) From 37d4ada93068c026c19e8ec36a3cb56508ca82e4 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Fri, 14 Aug 2026 00:10:10 -0700 Subject: [PATCH 05/45] refactor(config): worker lifecycle timeouts stay on settings.client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three endpoint-client worker waits (init, graceful shutdown, force kill) are client internals, not global run deadlines — restore them on HTTPClientConfig under their original names and drop them from Timeouts. CLI_QUICK_REFERENCE gains a run-lifetime timeline visualizing where every time knob acts. --- AGENTS.md | 2 +- docs/CLI_QUICK_REFERENCE.md | 72 +++++++++++++----- ...m_gptoss_120b_per_dataset_osl_example.yaml | 4 +- ...ractive_qwen3_vl_235b_a22b_shopify_8k.yaml | 6 +- .../offline_qwen3_vl_235b_a22b_shopify.yaml | 5 +- .../server_qwen3_vl_235b_a22b_shopify.yaml | 5 +- .../commands/benchmark/execute.py | 7 -- .../templates/concurrency_template_full.yaml | 6 +- .../templates/offline_template_full.yaml | 6 +- .../templates/online_template_full.yaml | 6 +- src/inference_endpoint/config/timeouts.py | 22 ++---- .../endpoint_client/config.py | 25 ++----- .../endpoint_client/worker_manager.py | 8 +- .../commands/test_benchmark_command.py | 3 +- tests/unit/config/test_timeouts.py | 73 +++---------------- tests/unit/config/test_yaml_loader.py | 7 +- 16 files changed, 101 insertions(+), 156 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9ceeec06..ffe50dedd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Dataset Manager --> Load Generator --> Endpoint Client --> External Endpoint | **Dataset Manager** | `src/inference_endpoint/dataset_manager/` | Loads JSONL, HuggingFace, CSV, JSON, Parquet datasets. `Dataset` base class with `load_sample()`/`num_samples()` interface | | **Metrics Aggregator** | `src/inference_endpoint/async_utils/services/metrics_aggregator/` | Subprocess. Subscribes to events, aggregates per-sample metrics into a `MetricsRegistry` (counters + HDR-histogram series + raw values), publishes `MetricsSnapshot` over IPC PUB at a configurable cadence (`SessionState`: `INITIALIZE` → `LIVE` → `DRAINING` → {`COMPLETE` \| `INTERRUPTED`}). Final snapshot is atomically written to `final_snapshot.json` as the **primary** Report source; the terminal pub/sub frame is a TUI "run finished" signal only. | | **Report** | `src/inference_endpoint/metrics/report.py` | `Report.from_snapshot(dict)` — pure-function builder consuming the dict form (`snapshot_to_dict`). Reads `final_snapshot.json` directly via `json.loads` (no Struct decode). Plumbs `complete = (state == "complete" and n_pending_tasks == 0)`; renders an explicit warning for `INTERRUPTED` runs. | -| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema split into focused modules (`schema.py` = BenchmarkConfig + EndpointConfig + root TestType/TestMode + re-export hub; `audit.py`, `model_params.py`, `datasets.py`, `settings.py` — each domain owns its models AND enums), `Timeouts` (`config/timeouts.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | +| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema split into focused modules (`schema.py` = BenchmarkConfig + EndpointConfig + root TestType/TestMode + re-export hub; `audit.py`, `model_params.py`, `datasets.py`, `settings.py` — each domain owns its models AND enums), `Timeouts` (`config/timeouts.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`; client worker-lifecycle timeouts stay on `settings.client`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | | **CLI** | `src/inference_endpoint/main.py`, `commands/benchmark/cli.py` | cyclopts-based, auto-generated from `schema.py` and `HTTPClientConfig` Pydantic models. Flat shorthands via `cyclopts.Parameter(alias=...)` | | **Async Utils** | `src/inference_endpoint/async_utils/` | `LoopManager` (uvloop + eager_task_factory), ZMQ transport layer, generic `MessageCodec[T]`-parametrized pub/sub, event publisher | | **OpenAI/SGLang** | `src/inference_endpoint/openai/`, `sglang/` | Protocol adapters and response accumulators for different API formats. `openai_completions` adapter (`completions_adapter.py`) sends pre-tokenized token IDs to `/v1/completions`, bypassing the server chat template — required for gpt-oss-120b on vLLM. `sglang` adapter sends to `/generate` via `input_ids`. Both apply `Harmonize()` client-side. | diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index c1d55dc5f..b09f2d80d 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -120,31 +120,63 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. ## Time Knobs All give-up deadlines live under `settings.timeouts`; the only workload duration is -`settings.runtime.max_duration_ms`. `null`/unset means "wait indefinitely" (or "off") everywhere. - -| YAML path | CLI flag | Semantics | -| --------------------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps the performance phase (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid | -| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog; firing aborts the entire run — report marked INTERRUPTED, non-zero exit | -| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | -| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | -| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | -| `settings.timeouts.worker_initialization_timeout_s` | `--worker-initialization-timeout-s` | Wait for endpoint-client worker processes to start (default 60) | -| `settings.timeouts.worker_graceful_shutdown_wait_s` | `--worker-graceful-shutdown-wait-s` | Post-run wait for workers to exit gracefully (default 0.5) | -| `settings.timeouts.worker_force_kill_timeout_s` | `--worker-force-kill-timeout-s` | Wait after SIGTERM before SIGKILL during worker teardown (default 0.5) | +`settings.runtime.max_duration_ms`; endpoint-client worker lifecycle timeouts are client +internals under `settings.client`. `null`/unset means "wait indefinitely" (or "off") everywhere. + +Where every knob acts over the life of a run: + +```text +run_benchmark ── run_timeout_s deadline captured here ─────────────────────────────┐ +│ │ +├─ setup: dataset + tokenizer load (counts against run_timeout_s) │ +├─ launch metrics/event-logger services ── service_ready_timeout_s │ +├─ start endpoint-client workers ── client.worker_initialization_timeout│ +│ │ +├─ WARMUP issue ──────────┤ drain ─┤ ── warmup_drain_timeout_s │ +│ │ +├─ PERFORMANCE issue ──────────┤ drain ─┤ │ +│ │ │ └─ performance_drain_timeout_s │ +│ └───────────────┴─ max_duration_ms caps ISSUING only; reaching it │ +│ ends the phase NORMALLY (valid report) and │ +│ SKIPS the drain — the two never run together │ +│ │ +├─ ACCURACY issue ──────────┤ drain ─┤ ── accuracy_drain_timeout_s │ +│ │ +├─ metrics drain (tokenize buffered ISL/OSL)── metrics_drain_timeout_s │ +│ (expiry FAILS the run: │ +│ complete: false + non-zero exit) │ +├─ worker shutdown ── client.worker_graceful_shutdown_wait│ +│ then client.worker_force_kill_timeout +└─ finalize: score accuracy, write artifacts │ + │ + run_timeout_s (whole-run watchdog) ───────────────────────────────────────────────┘ + firing at ANY point above aborts the run: report marked INTERRUPTED, non-zero exit +``` + +| YAML path | CLI flag | Semantics | +| ----------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | +| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog over everything above; firing aborts the entire run — report marked INTERRUPTED, non-zero exit | +| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | +| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | +| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | +| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | +| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | +| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | +| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | How the knobs compose: 1. **`--num-samples` / dataset-once defines the work.** An explicit `runtime.n_samples_to_issue` sets the sample count; omitting it issues the performance dataset once. -2. **`runtime.max_duration_ms` caps the performance phase** and ends it normally — remaining - samples are not issued, the report is valid. -3. **`timeouts.run_timeout_s` aborts the whole run** (every phase, drains included) — the report - is marked INTERRUPTED and the process exits non-zero. -4. **Per-phase drain timeouts bound the post-phase wait** for requests still in flight after a - phase stops issuing. +2. **`runtime.max_duration_ms` caps performance-phase issuing** and ends the phase normally — + remaining samples are not issued, in-flight requests are abandoned (no drain), the report is + valid. It does not bound the drain: issuing and draining are consecutive, never concurrent. +3. **Per-phase drain timeouts bound the post-phase wait** for requests still in flight after a + phase stops issuing on its own. +4. **`timeouts.run_timeout_s` is the only total-wall-time bound** (setup, every phase, every + drain) — firing aborts the run, marks the report INTERRUPTED, and exits non-zero. ## Environment Variables diff --git a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml index 351537fcd..85e865ba2 100644 --- a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml +++ b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_per_dataset_osl_example.yaml @@ -72,10 +72,8 @@ settings: type: "concurrency" target_concurrency: 1024 - timeouts: - worker_initialization_timeout_s: 300.0 - client: + worker_initialization_timeout: 300.0 num_workers: 16 log_level: "WARN" worker_gc_mode: "disabled" diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml index bd8cb0854..a41425503 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml @@ -31,6 +31,8 @@ settings: target_qps: 3 client: + # Increase for slow worker startup (spawn, imports). Default 60s may be too short. + worker_initialization_timeout: 120 num_workers: 5 transport: type: zmq @@ -38,10 +40,6 @@ settings: send_buffer_size: 16777216 max_connections: 1000 - timeouts: - # Increase for slow worker startup (spawn, imports). Default 60s may be too short. - worker_initialization_timeout_s: 120 - warmup: enabled: true # Enable warmup phase before performance run n_requests: 400 # Warmup request count (None = full dataset once) diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml index 3e393480d..1f02ea16f 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml @@ -29,15 +29,14 @@ settings: type: "max_throughput" client: + # Increase for slow worker startup (spawn, imports). Default 60s may be too short. + worker_initialization_timeout: 120 num_workers: 5 transport: type: zmq recv_buffer_size: 16777216 send_buffer_size: 16777216 max_connections: 1000 - timeouts: - # Increase for slow worker startup (spawn, imports). Default 60s may be too short. - worker_initialization_timeout_s: 120 warmup: enabled: true # Enable warmup phase before performance run n_requests: 1600 # Warmup request count (None = full dataset once) diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml index 9b9110ae9..9b835b249 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml @@ -30,6 +30,8 @@ settings: target_qps: 5 client: + # Increase for slow worker startup (spawn, imports). Default 60s may be too short. + worker_initialization_timeout: 120 num_workers: 5 transport: type: zmq @@ -37,9 +39,6 @@ settings: send_buffer_size: 16777216 max_connections: 1000 - timeouts: - # Increase for slow worker startup (spawn, imports). Default 60s may be too short. - worker_initialization_timeout_s: 120 warmup: enabled: true # Enable warmup phase before performance run n_requests: 1600 # Warmup request count (None = full dataset once) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 8f079a54f..fc2739141 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -721,7 +721,6 @@ async def _create_issuer( api_type: APIType = config.endpoint_config.api_type # client.api_type is propagated from endpoint_config.api_type by # BenchmarkConfig._propagate_client_api_type — no override needed here. - timeouts = config.settings.timeouts client_overrides: dict = { "endpoint_urls": [ urljoin(e.rstrip("/") + "/", api_type.default_route()) @@ -730,12 +729,6 @@ async def _create_issuer( "api_key": config.endpoint_config.api_key, "event_logs_dir": ctx.report_dir, "cpu_affinity": ctx.affinity_plan, - # Worker lifecycle deadlines live in settings.timeouts; the - # HTTPClientConfig fields are excluded runtime carriers populated - # only here. - "worker_initialization_timeout_s": timeouts.worker_initialization_timeout_s, - "worker_graceful_shutdown_wait_s": timeouts.worker_graceful_shutdown_wait_s, - "worker_force_kill_timeout_s": timeouts.worker_force_kill_timeout_s, } if ctx.accuracy_only: # Single-stream (num_workers=1, max_connections=1) is baked into diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 97a13f6be..47e11e7d5 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -76,6 +76,9 @@ settings: linger: -1 # ZMQ linger on close (-1=block until sent) immediate: 1 # ZMQ IMMEDIATE (1=only enqueue on ready) stream_all_chunks: false # Stream all chunks to main thread (caution: perf overhead) + worker_initialization_timeout: 60.0 # Worker init timeout (seconds) + worker_graceful_shutdown_wait: 0.5 # Post-run graceful shutdown wait (seconds) + worker_force_kill_timeout: 0.5 # Force kill timeout after graceful wait (seconds) insecure: false # Skip TLS certificate verification max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) @@ -87,9 +90,6 @@ settings: performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. - worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) - worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) - worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index d232f702f..825440624 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -76,6 +76,9 @@ settings: linger: -1 # ZMQ linger on close (-1=block until sent) immediate: 1 # ZMQ IMMEDIATE (1=only enqueue on ready) stream_all_chunks: false # Stream all chunks to main thread (caution: perf overhead) + worker_initialization_timeout: 60.0 # Worker init timeout (seconds) + worker_graceful_shutdown_wait: 0.5 # Post-run graceful shutdown wait (seconds) + worker_force_kill_timeout: 0.5 # Force kill timeout after graceful wait (seconds) insecure: false # Skip TLS certificate verification max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) @@ -87,9 +90,6 @@ settings: performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. - worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) - worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) - worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index aad246771..f480473e1 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -77,6 +77,9 @@ settings: linger: -1 # ZMQ linger on close (-1=block until sent) immediate: 1 # ZMQ IMMEDIATE (1=only enqueue on ready) stream_all_chunks: false # Stream all chunks to main thread (caution: perf overhead) + worker_initialization_timeout: 60.0 # Worker init timeout (seconds) + worker_graceful_shutdown_wait: 0.5 # Post-run graceful shutdown wait (seconds) + worker_force_kill_timeout: 0.5 # Force kill timeout after graceful wait (seconds) insecure: false # Skip TLS certificate verification max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) @@ -88,9 +91,6 @@ settings: performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. - worker_initialization_timeout_s: 60.0 # Endpoint-client worker init timeout (seconds) - worker_graceful_shutdown_wait_s: 0.5 # Endpoint-client post-run graceful shutdown wait (seconds) - worker_force_kill_timeout_s: 0.5 # Endpoint-client force kill timeout after graceful wait (seconds) warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) diff --git a/src/inference_endpoint/config/timeouts.py b/src/inference_endpoint/config/timeouts.py index abd22c3f8..1a6d2124d 100644 --- a/src/inference_endpoint/config/timeouts.py +++ b/src/inference_endpoint/config/timeouts.py @@ -16,11 +16,12 @@ """Global waits and deadlines (the ``settings.timeouts`` block). Split criterion: one module per config domain; every global time knob that -bounds how long the harness waits — startup readiness, per-phase drains, the -worker lifecycle, and the whole-run watchdog — lives here. Workload durations +bounds how long the harness waits — startup readiness, per-phase drains, and +the whole-run watchdog — lives here. Workload durations (``runtime.max_duration_ms``) are part of the benchmark definition, not waits, -and stay in ``runtime``. Dataset-scoped time knobs (e.g. agentic -``turn_timeout_s``) stay in their dataset config blocks. +and stay in ``runtime``. Client worker-lifecycle timeouts stay on +``settings.client`` (endpoint-client internals). Dataset-scoped time knobs +(e.g. agentic ``turn_timeout_s``) stay in their dataset config blocks. """ from __future__ import annotations @@ -131,16 +132,3 @@ class Timeouts(WithUpdatesMixin, BaseModel): "run_benchmark exits non-zero." ), ) - worker_initialization_timeout_s: float = Field( - 60.0, ge=0, description="Endpoint-client worker init timeout (seconds)" - ) - worker_graceful_shutdown_wait_s: float = Field( - 0.5, - ge=0, - description="Endpoint-client post-run graceful shutdown wait (seconds)", - ) - worker_force_kill_timeout_s: float = Field( - 0.5, - ge=0, - description="Endpoint-client force kill timeout after graceful wait (seconds)", - ) diff --git a/src/inference_endpoint/endpoint_client/config.py b/src/inference_endpoint/endpoint_client/config.py index 6802a2851..b2839996d 100644 --- a/src/inference_endpoint/endpoint_client/config.py +++ b/src/inference_endpoint/endpoint_client/config.py @@ -187,24 +187,15 @@ class HTTPClientConfig(WithUpdatesMixin, BaseModel): False, description="Stream all chunks to main thread (caution: perf overhead)" ) - # Worker lifecycle timeouts — runtime carriers. The authoritative user - # knobs live in settings.timeouts; setup copies them here (no CLI flag, - # never serialized). WithUpdatesMixin.with_updates reads exclude=True - # fields directly, so copies preserve the injected values. - worker_initialization_timeout_s: Annotated[ - float, cyclopts.Parameter(parse=False) - ] = Field(60.0, exclude=True, description="Worker init timeout (seconds)") - worker_graceful_shutdown_wait_s: Annotated[ - float, cyclopts.Parameter(parse=False) - ] = Field( - 0.5, exclude=True, description="Post-run graceful shutdown wait (seconds)" + # Worker lifecycle timeouts + worker_initialization_timeout: float = Field( + 60.0, description="Worker init timeout (seconds)" + ) + worker_graceful_shutdown_wait: float = Field( + 0.5, description="Post-run graceful shutdown wait (seconds)" ) - worker_force_kill_timeout_s: Annotated[float, cyclopts.Parameter(parse=False)] = ( - Field( - 0.5, - exclude=True, - description="Force kill timeout after graceful wait (seconds)", - ) + worker_force_kill_timeout: float = Field( + 0.5, description="Force kill timeout after graceful wait (seconds)" ) # Set to True to skip certificate verification (e.g. self-signed certs). diff --git a/src/inference_endpoint/endpoint_client/worker_manager.py b/src/inference_endpoint/endpoint_client/worker_manager.py index bd0304447..ae0d194df 100644 --- a/src/inference_endpoint/endpoint_client/worker_manager.py +++ b/src/inference_endpoint/endpoint_client/worker_manager.py @@ -92,7 +92,7 @@ async def initialize(self) -> None: except TimeoutError as e: raise TimeoutError( - f"Workers failed to initialize within {self.http_config.worker_initialization_timeout_s}s" + f"Workers failed to initialize within {self.http_config.worker_initialization_timeout}s" ) from e finally: @@ -130,7 +130,7 @@ def _pin_workers(self) -> None: async def _wait_for_workers_with_liveness_check(self) -> None: """Wait for workers, checking liveness at 10% intervals.""" - timeout = self.http_config.worker_initialization_timeout_s + timeout = self.http_config.worker_initialization_timeout check_interval = timeout * 0.10 if timeout else 1.0 start = time.monotonic() @@ -165,7 +165,7 @@ async def shutdown(self) -> None: if worker.is_alive(): worker.terminate() - await asyncio.sleep(self.http_config.worker_graceful_shutdown_wait_s) + await asyncio.sleep(self.http_config.worker_graceful_shutdown_wait) # Force kill remaining for worker in self.workers: @@ -176,7 +176,7 @@ async def shutdown(self) -> None: await asyncio.gather( *( asyncio.to_thread( - worker.join, timeout=self.http_config.worker_force_kill_timeout_s + worker.join, timeout=self.http_config.worker_force_kill_timeout ) for worker in self.workers ) diff --git a/tests/integration/commands/test_benchmark_command.py b/tests/integration/commands/test_benchmark_command.py index 1aec94185..6023f7692 100644 --- a/tests/integration/commands/test_benchmark_command.py +++ b/tests/integration/commands/test_benchmark_command.py @@ -375,8 +375,7 @@ def _resolve_template(template_path: Path, server_url: str) -> dict: # The other 5 templates benefit from warm module / IPC caches and don't # need the headroom. 120 s is a generous safety margin that does not # change the production default, only this integration test. - data["settings"].setdefault("timeouts", {}) - data["settings"]["timeouts"]["worker_initialization_timeout_s"] = 120.0 + data["settings"].setdefault("client", {})["worker_initialization_timeout"] = 120.0 # Accuracy datasets can't run e2e against echo server (no scorer), so keep only performance datasets. data["datasets"] = [ diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index 0c61b6d6a..ef958a3f1 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -22,7 +22,6 @@ import random import pytest -import yaml from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( BenchmarkConfig, @@ -53,9 +52,6 @@ def test_defaults(self): assert cfg.performance_drain_timeout_s is None assert cfg.accuracy_drain_timeout_s is None assert cfg.metrics_drain_timeout_s is None - assert cfg.worker_initialization_timeout_s == 60.0 - assert cfg.worker_graceful_shutdown_wait_s == 0.5 - assert cfg.worker_force_kill_timeout_s == 0.5 @pytest.mark.unit def test_mounted_on_settings_by_default(self): @@ -101,15 +97,7 @@ def test_deadline_none_means_unlimited(self, field): assert getattr(Timeouts(**{field: None}), field) is None @pytest.mark.unit - @pytest.mark.parametrize( - "field", - [ - "service_ready_timeout_s", - "worker_initialization_timeout_s", - "worker_graceful_shutdown_wait_s", - "worker_force_kill_timeout_s", - ], - ) + @pytest.mark.parametrize("field", ["service_ready_timeout_s"]) def test_ge_zero_fields_accept_zero_reject_negative(self, field): assert getattr(Timeouts(**{field: 0}), field) == 0.0 with pytest.raises(ValidationError): @@ -153,54 +141,19 @@ def test_top_level_timeout_rejected(self): with pytest.raises(ValidationError, match="timeout"): BenchmarkConfig(**_MINIMAL_KWARGS, timeout=42.0) - @pytest.mark.unit - def test_client_worker_knob_rejected(self): - with pytest.raises(ValidationError, match="worker_initialization_timeout"): - BenchmarkConfig( - **_MINIMAL_KWARGS, - settings={"client": {"worker_initialization_timeout": 120.0}}, - ) - - @pytest.mark.unit - def test_client_worker_knob_rejected_from_yaml(self, tmp_path): - yaml_content = """ -type: "offline" -model_params: - name: "test-model" -endpoint_config: - endpoints: ["http://test:8000"] -datasets: - - path: "test.jsonl" -settings: - client: - worker_initialization_timeout: 120 -""" - config_file = tmp_path / "stale.yaml" - config_file.write_text(yaml_content) - with pytest.raises(ValidationError, match="worker_initialization_timeout"): - BenchmarkConfig.from_yaml_file(config_file) - -class TestWorkerFieldsHiddenFromSerialization: +class TestClientWorkerKnobs: @pytest.mark.unit - def test_yaml_roundtrip_excludes_worker_carrier_fields(self, tmp_path): - """The runtime-carrier worker fields on the client never serialize, so - a persisted config reloads cleanly under extra=forbid.""" - config = BenchmarkConfig(**_MINIMAL_KWARGS) - out = tmp_path / "roundtrip.yaml" - config.to_yaml_file(out) - - dumped = yaml.safe_load(out.read_text()) - client_block = dumped.get("settings", {}).get("client", {}) or {} - carrier_fields = { - "worker_initialization_timeout_s", - "worker_graceful_shutdown_wait_s", - "worker_force_kill_timeout_s", - } - assert not carrier_fields & client_block.keys() - - loaded = BenchmarkConfig.from_yaml_file(out) - assert loaded.settings.timeouts == config.settings.timeouts + def test_worker_lifecycle_knobs_live_on_client(self): + """Worker lifecycle timeouts are endpoint-client internals and stay on + settings.client, not in the timeouts block.""" + config = BenchmarkConfig( + **_MINIMAL_KWARGS, + settings={"client": {"worker_initialization_timeout": 120.0}}, + ) + assert config.settings.client.worker_initialization_timeout == 120.0 + with pytest.raises(ValidationError): + Timeouts(worker_initialization_timeout_s=90.0) class TestTimeoutsYAMLRoundtrip: @@ -221,7 +174,6 @@ def test_yaml_block_loads(self, tmp_path): performance_drain_timeout_s: 30.0 accuracy_drain_timeout_s: null metrics_drain_timeout_s: 300.0 - worker_initialization_timeout_s: 90 """ config_file = tmp_path / "timeouts.yaml" config_file.write_text(yaml_content) @@ -232,7 +184,6 @@ def test_yaml_block_loads(self, tmp_path): assert timeouts.performance_drain_timeout_s == 30.0 assert timeouts.accuracy_drain_timeout_s is None assert timeouts.metrics_drain_timeout_s == 300.0 - assert timeouts.worker_initialization_timeout_s == 90.0 class TestMaxDurationSuffix: diff --git a/tests/unit/config/test_yaml_loader.py b/tests/unit/config/test_yaml_loader.py index d1d3d5181..ef582b31a 100644 --- a/tests/unit/config/test_yaml_loader.py +++ b/tests/unit/config/test_yaml_loader.py @@ -45,8 +45,6 @@ def test_load_valid_yaml(self, tmp_path): path: "test.jsonl" settings: - timeouts: - worker_initialization_timeout_s: 120 load_pattern: type: "max_throughput" client: @@ -67,7 +65,6 @@ def test_load_valid_yaml(self, tmp_path): assert config.name == "test-config" assert config.type == BenchmarkTestType.OFFLINE assert len(config.datasets) == 1 - assert config.settings.timeouts.worker_initialization_timeout_s == 120.0 assert config.settings.client.transport.recv_buffer_size == 16777216 assert config.settings.client.transport.send_buffer_size == 8388608 @@ -245,8 +242,8 @@ def test_serialize_deserialize_roundtrip(self, tmp_path): ) assert loaded.settings.load_pattern.type == original.settings.load_pattern.type assert ( - loaded.settings.timeouts.worker_initialization_timeout_s - == original.settings.timeouts.worker_initialization_timeout_s + loaded.settings.client.worker_initialization_timeout + == original.settings.client.worker_initialization_timeout ) assert ( loaded.settings.client.transport.recv_buffer_size From 404c65b617207df6e7184c138a0c64ea493a899e Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Fri, 14 Aug 2026 00:19:08 -0700 Subject: [PATCH 06/45] chore(tests): drop stray untracked scratch tests swept into the branch test_metrics_preflight_tap.py references a local scratchpad path and test_protocol.py targets transport features that do not exist on main; neither belongs to this PR. --- scripts/bench_drain_tokenize.py | 301 ------------- .../async_utils/transport/test_protocol.py | 113 ----- .../scripts/test_metrics_preflight_tap.py | 410 ------------------ 3 files changed, 824 deletions(-) delete mode 100644 scripts/bench_drain_tokenize.py delete mode 100644 tests/unit/async_utils/transport/test_protocol.py delete mode 100644 tests/unit/scripts/test_metrics_preflight_tap.py diff --git a/scripts/bench_drain_tokenize.py b/scripts/bench_drain_tokenize.py deleted file mode 100644 index 5a0ca00d0..000000000 --- a/scripts/bench_drain_tokenize.py +++ /dev/null @@ -1,301 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Apples-to-apples benchmark of OUTPUT-tokenization strategies for the -metrics-aggregator drain (OSL / TPOT). - -Why this exists: at the end of a run the aggregator tokenizes every sample's -output to derive OSL/TPOT. The live impl fires one asyncio task per sample, -each awaiting ``loop.run_in_executor(thread_pool, len(tok.tokenize(text)))`` -(see ``metrics_aggregator/metrics_table.py::AsyncTokenTrigger.fire`` + -``token_metrics.py::TokenizePool.token_count_async``). This script reproduces -that exact pattern standalone and pits it against a single batched -``tokenizer(texts)`` call (the batched strategy from the prior ISL ablation) -so the cost of the current design — and the win from replacing it — is measured -on identical inputs. Measured (Qwen2.5-0.5B, 48-core, 12 workers): encode_batch -is ~4.6x the current per-sample async pattern on short outputs, ~2.0x on the -realistic right-skewed OSL distribution (mean ~3.8k tok) — and, more -importantly, removes the per-sample asyncio-task backlog (1 task/sample) that -drives the drain timeout. The single batched Rust call beats thread-sharding -(the HF fast tokenizer already parallelises a batch internally). - -Strategies (all plain ``tokenize``, no chat template — matches the OSL/TPOT -text path taken when the output has no tool_calls): - - current_async EXACT live drain pattern: per-sample loop.create_task -> - TokenizePool.token_count_async -> run_in_executor, gathered. - sync_loop Serial ``len(tok.tokenize(t))`` — isolates raw tokenize cost - from asyncio/thread-pool overhead. - batch One ``tokenizer(texts)`` Rust call over all texts. - thread_batch Shard texts across ``--workers`` threads, each batch-tokenizes - its shard (GIL released inside the Rust call). - -Usage: - uv run python scripts/bench_drain_tokenize.py \ - --model Qwen/Qwen2.5-0.5B-Instruct --n-samples 20000 --runs 3 -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import os -import random -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from typing import Any - -from inference_endpoint.async_utils.services.metrics_aggregator.token_metrics import ( - TokenizePool, -) -from transformers import AutoTokenizer - -_WORDS = ( - "the quick brown fox jumps over the lazy dog inference benchmark " - "tokenization latency throughput performance model weights attention " - "transformer layer norm softmax gradient embedding sequence decode " -).split() - - -# Measured OSL token-length distribution (max_new_tokens=20000 cap; heavily -# right-skewed: median 2153, mean 3824). Piecewise-linear inverse-CDF from the -# measured percentiles so generated lengths match the real drain workload. -_OSL_PCTL: tuple[tuple[float, int], ...] = ( - (0, 177), - (1, 303), - (5, 463), - (10, 578), - (25, 951), - (50, 2153), - (75, 4977), - (80, 6001), - (90, 9564), - (95, 13510), - (97, 16422), - (99, 20000), - (100, 20000), -) - - -def _sample_osl(rng: random.Random) -> int: - p = rng.random() * 100.0 - for (p0, v0), (p1, v1) in zip(_OSL_PCTL, _OSL_PCTL[1:], strict=False): - if p <= p1: - frac = (p - p0) / (p1 - p0) if p1 > p0 else 0.0 - return int(v0 + frac * (v1 - v0)) - return _OSL_PCTL[-1][1] - - -def _make_outputs( - n: int, profile: str, min_words: int, max_words: int, seed: int = 42 -) -> list[str]: - """Synthetic model-output texts (plain text, the OSL/TPOT common case). - - profile='mlperf' draws word counts from the measured OSL distribution - (token≈word for these common words); 'uniform' uses [min_words, max_words]. - """ - rng = random.Random(seed) - if profile == "mlperf": - lengths = [_sample_osl(rng) for _ in range(n)] - else: - lengths = [rng.randint(min_words, max_words) for _ in range(n)] - return [" ".join(rng.choices(_WORDS, k=length)) for length in lengths] - - -def _result(name: str, secs: float, n: int, total_tokens: int) -> dict[str, Any]: - return { - "strategy": name, - "wall_s": round(secs, 4), - "samples_per_s": round(n / secs) if secs else 0, - "tokens_per_s": round(total_tokens / secs) if secs else 0, - } - - -def bench_sync_loop(texts: list[str], tok: Any) -> tuple[float, int]: - t0 = time.perf_counter() - total = 0 - for t in texts: - total += len(tok.tokenize(t)) - return time.perf_counter() - t0, total - - -def bench_batch(texts: list[str], tok: Any) -> tuple[float, int]: - t0 = time.perf_counter() - enc = tok(texts, add_special_tokens=False, return_attention_mask=False) - total = sum(len(ids) for ids in enc["input_ids"]) - return time.perf_counter() - t0, total - - -def bench_encode_batch(texts: list[str], tok: Any) -> tuple[float, int]: - """Raw Rust ``encode_batch`` on the backend tokenizer — skips the - BatchEncoding/padding wrapper that ``tokenizer(...)`` builds. We only need - counts, so this is the leanest count-only path.""" - backend = tok.backend_tokenizer - # encode_batch_fast (tokenizers>=0.20) skips offset computation; fall back - # to encode_batch where unavailable. - fn = getattr(backend, "encode_batch_fast", None) or backend.encode_batch - t0 = time.perf_counter() - encs = fn(texts, add_special_tokens=False) - total = sum(len(e.ids) for e in encs) - return time.perf_counter() - t0, total - - -def bench_batch_chunked( - texts: list[str], tok: Any, chunk: int = 50_000 -) -> tuple[float, int]: - """Chunked batches — bounds peak memory for very large drains while still - feeding the Rust parallel path large slices.""" - t0 = time.perf_counter() - total = 0 - for i in range(0, len(texts), chunk): - enc = tok( - texts[i : i + chunk], - add_special_tokens=False, - return_attention_mask=False, - ) - total += sum(len(ids) for ids in enc["input_ids"]) - return time.perf_counter() - t0, total - - -def bench_thread_batch( - texts: list[str], tokenizer_name: str, workers: int -) -> tuple[float, int]: - # Each worker loads its own tokenizer (thread-local, like TokenizePool) and - # batch-tokenizes a contiguous shard. - shards: list[list[str]] = [texts[i::workers] for i in range(workers)] - tls = threading.local() - - def _work_tls(shard: list[str]) -> int: - tok = getattr(tls, "tok", None) - if tok is None: - tok = AutoTokenizer.from_pretrained(tokenizer_name) - tls.tok = tok - if not shard: - return 0 - enc = tok(shard, add_special_tokens=False, return_attention_mask=False) - return sum(len(ids) for ids in enc["input_ids"]) - - with ThreadPoolExecutor(max_workers=workers) as ex: - # Warm tokenizers on every thread before timing. - list(ex.map(lambda _: _work_tls([]), range(workers))) - t0 = time.perf_counter() - total = sum(ex.map(_work_tls, shards)) - return time.perf_counter() - t0, total - - -async def bench_current_async( - texts: list[str], pool: TokenizePool -) -> tuple[float, int]: - """EXACT live drain pattern: one asyncio task per sample, each awaiting - pool.token_count_async (-> loop.run_in_executor), then gathered.""" - loop = asyncio.get_running_loop() - t0 = time.perf_counter() - tasks = [loop.create_task(pool.token_count_async(t, loop)) for t in texts] - counts = await asyncio.gather(*tasks) - return time.perf_counter() - t0, sum(counts) - - -def _run_current_async(texts: list[str], pool: TokenizePool) -> tuple[float, int]: - try: - import uvloop # the aggregator runs on uvloop; match it. - - runner = uvloop.run - except ImportError: - runner = asyncio.run - return runner(bench_current_async(texts, pool)) - - -def main() -> int: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--model", default="Qwen/Qwen2.5-0.5B-Instruct") - ap.add_argument("--n-samples", type=int, default=20000) - ap.add_argument("--runs", type=int, default=3) - ap.add_argument( - "--workers", - type=int, - default=max(2, (os.cpu_count() or 16) // 4), - help="TokenizePool / thread_batch worker count (aggregator default).", - ) - ap.add_argument("--osl-profile", choices=("mlperf", "uniform"), default="mlperf") - ap.add_argument("--min-words", type=int, default=20) - ap.add_argument("--max-words", type=int, default=200) - ap.add_argument("--output", default="") - args = ap.parse_args() - - print(f"Loading tokenizer: {args.model}") - AutoTokenizer.from_pretrained(args.model) # warm cache before timing - tok = AutoTokenizer.from_pretrained(args.model) - - print( - f"Generating {args.n_samples} synthetic outputs (profile={args.osl_profile})..." - ) - texts = _make_outputs( - args.n_samples, args.osl_profile, args.min_words, args.max_words - ) - avg_words = sum(t.count(" ") + 1 for t in texts) / len(texts) - print( - f"profile={args.osl_profile} | avg {avg_words:.0f} words/output " - f"| workers={args.workers}\n" - ) - - pool = TokenizePool(args.model, n_workers=args.workers) - results: list[dict[str, Any]] = [] - try: - strategies = [ - ("current_async", lambda: _run_current_async(texts, pool)), - ("sync_loop", lambda: bench_sync_loop(texts, tok)), - ("batch", lambda: bench_batch(texts, tok)), - ("batch_chunked", lambda: bench_batch_chunked(texts, tok)), - ("encode_batch", lambda: bench_encode_batch(texts, tok)), - ( - "thread_batch", - lambda: bench_thread_batch(texts, args.model, args.workers), - ), - ] - for name, fn in strategies: - best_secs = float("inf") - total_tokens = 0 - for _ in range(args.runs): - secs, total_tokens = fn() - best_secs = min(best_secs, secs) - r = _result(name, best_secs, args.n_samples, total_tokens) - results.append(r) - print( - f"{name:<16} {r['wall_s']:>9.4f}s " - f"{r['samples_per_s']:>12,} samples/s " - f"{r['tokens_per_s']:>14,} tok/s" - ) - finally: - pool.close() - - base = next(r for r in results if r["strategy"] == "current_async") - print("\nspeedup vs current_async (best wall):") - for r in results: - if r["strategy"] != "current_async" and r["samples_per_s"]: - print( - f" {r['strategy']:<16} {r['samples_per_s'] / base['samples_per_s']:>6.1f}x" - ) - - if args.output: - with open(args.output, "w") as f: - json.dump({"args": vars(args), "results": results}, f, indent=2) - print(f"\nwrote {args.output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/unit/async_utils/transport/test_protocol.py b/tests/unit/async_utils/transport/test_protocol.py deleted file mode 100644 index 16dc8da6a..000000000 --- a/tests/unit/async_utils/transport/test_protocol.py +++ /dev/null @@ -1,113 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import asyncio -from collections import deque - -import pytest -from inference_endpoint.async_utils.transport.protocol import MessageSubscriber - - -class _IntCodec: - def encode(self, item: int) -> tuple[bytes, bytes]: - return b"test____", str(item).encode() - - def decode(self, payload: bytes) -> int: - return int(payload) - - def on_decode_error(self, payload: bytes, exc: Exception) -> int | None: - return None - - -class _QueueSubscriber(MessageSubscriber[int]): - def __init__( - self, - loop: asyncio.AbstractEventLoop, - payloads: list[bytes], - *, - max_read_batch_size: int, - ) -> None: - super().__init__(_IntCodec(), "test://subscriber", loop) - self._payloads = deque(payloads) - self._max_read_batch_size = max_read_batch_size - self.batches: list[list[int]] = [] - self.received: list[int] = [] - self.done = asyncio.Event() - self.expected = len(payloads) - self.release = asyncio.Event() - self.block_processing = False - self.active = 0 - self.max_active = 0 - - def receive(self) -> bytes | None: - if not self._payloads: - raise StopIteration - return self._payloads.popleft() - - async def process(self, items: list[int]) -> None: - self.active += 1 - self.max_active = max(self.max_active, self.active) - if self.block_processing: - await self.release.wait() - self.batches.append(items) - self.received.extend(items) - self.active -= 1 - if len(self.received) >= self.expected: - self.done.set() - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_subscriber_caps_each_read_and_reschedules_without_new_edge(): - subscriber = _QueueSubscriber( - asyncio.get_running_loop(), - [str(i).encode() for i in range(5)], - max_read_batch_size=2, - ) - - subscriber._on_readable() - await asyncio.wait_for(subscriber.done.wait(), timeout=1) - - assert subscriber.received == [0, 1, 2, 3, 4] - assert subscriber.batches == [[0, 1], [2, 3], [4]] - - -@pytest.mark.unit -@pytest.mark.asyncio -async def test_subscriber_processes_batches_single_flight_in_fifo_order(): - subscriber = _QueueSubscriber( - asyncio.get_running_loop(), [b"1"], max_read_batch_size=4 - ) - subscriber.block_processing = True - subscriber.expected = 2 - - subscriber._on_readable() - subscriber._payloads.append(b"2") - subscriber._on_readable() - await asyncio.sleep(0) - subscriber.release.set() - await asyncio.wait_for(subscriber.done.wait(), timeout=1) - - assert subscriber.received == [1, 2] - assert subscriber.max_active == 1 - - -@pytest.mark.unit -def test_none_payloads_count_toward_read_budget_and_close_cancels_resume(): - subscriber = _QueueSubscriber( - asyncio.new_event_loop(), - [None, None, b"3"], # type: ignore[list-item] - max_read_batch_size=2, - ) - try: - subscriber._on_readable() - - assert list(subscriber._payloads) == [b"3"] - assert subscriber._read_continuation is not None - - subscriber.close() - assert subscriber._read_continuation is None - finally: - subscriber.loop.close() diff --git a/tests/unit/scripts/test_metrics_preflight_tap.py b/tests/unit/scripts/test_metrics_preflight_tap.py deleted file mode 100644 index 9fdedbe69..000000000 --- a/tests/unit/scripts/test_metrics_preflight_tap.py +++ /dev/null @@ -1,410 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Focused tests for the experiment-only GPT-OSS metrics preflight tap.""" - -# ruff: noqa: I001 -# Keep import layout stable across the pinned pre-commit and local uv ruff. - -from __future__ import annotations - -import csv -import importlib.util -import json -import os -import subprocess -import sys -from pathlib import Path - -import pytest -from inference_endpoint.async_utils.services.metrics_aggregator.snapshot import ( - CounterStat, - MetricsSnapshot, - MetricsSnapshotCodec, - SessionState, -) -from inference_endpoint.core.record import TOPIC_FRAME_SIZE - -pytestmark = pytest.mark.unit - - -def _load_tap(): - path = Path("scratchpad/gptoss_nvl144_pr334_vvv_20260728/metrics_preflight_tap.py") - spec = importlib.util.spec_from_file_location("metrics_preflight_tap", path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -tap = _load_tap() - - -def _write_process( - proc_root: Path, - *, - pid: int, - ppid: int, - argv: list[str], - status: str = "VmRSS:\t123 kB\nVmHWM:\t456 kB\n", -) -> None: - proc_dir = proc_root / str(pid) - proc_dir.mkdir() - (proc_dir / "stat").write_text(f"{pid} (command with spaces) S {ppid} 0 0\n") - (proc_dir / "cmdline").write_bytes(b"\0".join(a.encode() for a in argv) + b"\0") - (proc_dir / "status").write_text(status) - - -def _snapshot( - counter: int, - *, - state: SessionState = SessionState.LIVE, - pending: int = 0, - issued: int = 10, - completed: int = 5, -) -> MetricsSnapshot: - return MetricsSnapshot( - counter=counter, - timestamp_ns=counter * 100, - state=state, - n_pending_tasks=pending, - metrics=[ - CounterStat("total_samples_issued", issued), - CounterStat("total_samples_completed", completed), - CounterStat("total_samples_failed", 0), - ], - ) - - -class TestProcessDiscovery: - def test_finds_only_aggregator_below_root(self, tmp_path: Path) -> None: - proc = tmp_path / "proc" - proc.mkdir() - _write_process(proc, pid=100, ppid=1, argv=["benchmark"]) - _write_process(proc, pid=101, ppid=100, argv=["worker"]) - _write_process( - proc, - pid=102, - ppid=101, - argv=[ - "python", - "-m", - tap.AGGREGATOR_MODULE, - "--socket-dir", - "/dev/shm/zmq_a", - "--metrics-socket=metrics_a", - ], - ) - _write_process( - proc, - pid=200, - ppid=1, - argv=["python", "-m", tap.AGGREGATOR_MODULE], - ) - - found = tap.find_aggregator_descendant(100, proc) - - assert found is not None - assert found.pid == 102 - assert tap.parse_aggregator_socket_args(found.argv) == ( - "/dev/shm/zmq_a", - "metrics_a", - ) - assert ( - tap.metrics_ipc_address("/dev/shm/zmq_a", "metrics_a") - == "ipc:///dev/shm/zmq_a/metrics_a" - ) - - def test_missing_socket_arg_is_rejected(self) -> None: - with pytest.raises(ValueError, match="metrics-socket"): - tap.parse_aggregator_socket_args(["--socket-dir", "/tmp/x"]) - - -class TestSampling: - def test_reads_proc_cgroup_meminfo_and_tmpfs(self, tmp_path: Path) -> None: - proc = tmp_path / "proc" - proc.mkdir() - _write_process(proc, pid=42, ppid=1, argv=["aggregator"]) - (proc / "42" / "cgroup").write_text("0::/job/step\n") - (proc / "meminfo").write_text( - "MemTotal: 10000 kB\nMemAvailable: 2500 kB\n" - ) - - cgroup_root = tmp_path / "cgroup" - cgroup = cgroup_root / "job" / "step" - cgroup.mkdir(parents=True) - (cgroup / "memory.current").write_text("1000\n") - (cgroup / "memory.peak").write_text("2000\n") - (cgroup / "memory.max").write_text("3000\n") - (cgroup / "memory.events").write_text("oom 2\noom_kill 1\n") - - events = tmp_path / "benchmark_1" / "events" - events.mkdir(parents=True) - (events / "events.jsonl").write_bytes(b"x" * 17) - - location = tap._find_cgroup(42, proc, cgroup_root) - obs = tap.sample_memory( - 42, - location, - str(tmp_path / "benchmark_*" / "events" / "events.jsonl"), - proc, - ) - - assert obs == tap.MemoryObservation( - aggregator_alive=True, - rss_kib=123, - hwm_kib=456, - cgroup_current_bytes=1000, - cgroup_peak_bytes=2000, - cgroup_max_bytes=3000, - cgroup_oom=2, - cgroup_oom_kill=1, - mem_available_kib=2500, - mem_total_kib=10000, - tmpfs_event_files=1, - tmpfs_events_bytes=17, - ) - - -class TestSnapshotsAndArtifacts: - def test_decodes_frame_and_tracks_pending_memory_high_water( - self, tmp_path: Path - ) -> None: - codec = MetricsSnapshotCodec() - first = _snapshot(1, pending=3) - second = _snapshot( - 4, - state=SessionState.DRAINING, - pending=7, - issued=20, - completed=20, - ) - topic, payload = codec.encode(first) - assert len(topic) == TOPIC_FRAME_SIZE - assert tap.decode_metrics_frame(topic + payload, codec) == first - - stats = tap.MonitorStats( - started_wall_ns=100, - started_monotonic_ns=100, - root_pid=1, - aggregator_pid=42, - ) - stats.observe_snapshot(first, 1_000_000_000) - stats.observe_snapshot(second, 3_500_000_000) - stats.observe_memory( - tap.MemoryObservation( - aggregator_alive=True, - rss_kib=11, - hwm_kib=12, - cgroup_current_bytes=13, - cgroup_peak_bytes=14, - cgroup_max_bytes=15, - cgroup_oom=0, - cgroup_oom_kill=0, - mem_available_kib=16, - mem_total_kib=17, - tmpfs_event_files=1, - tmpfs_events_bytes=18, - ) - ) - - summary = stats.to_dict(ended_wall_ns=4_000_000_000, csv_path=tmp_path / "x") - assert summary["published_pending_high_water"] == 7 - assert summary["pending_at_first_draining"] == 7 - assert summary["counter_gap_total"] == 2 - assert summary["counter_gap_max"] == 2 - assert summary["max_snapshot_gap_s"] == 2.5 - assert summary["aggregator_rss_high_water_kib"] == 11 - assert summary["tmpfs_events_high_water_bytes"] == 18 - assert summary["telemetry_capture_valid"] is True - assert summary["telemetry_capture_failures"] == [] - - def test_capture_gate_requires_rss_and_oom_counters(self) -> None: - stats = tap.MonitorStats( - started_wall_ns=100, - started_monotonic_ns=100, - root_pid=1, - aggregator_pid=42, - cgroup_version=2, - snapshots_received=2, - published_pending_high_water=7, - aggregator_reported_hwm_high_water_kib=12, - cgroup_memory_current_high_water_bytes=13, - cgroup_memory_peak_high_water_bytes=14, - ) - - assert tap.telemetry_capture_failures(stats) == [ - "aggregator_rss_missing", - "cgroup_oom_missing", - "cgroup_oom_kill_missing", - ] - - stats.cgroup_version = 1 - assert tap.telemetry_capture_failures(stats) == [ - "aggregator_rss_missing", - "cgroup_oom_missing", - ] - - def test_csv_finalization_and_atomic_summary(self, tmp_path: Path) -> None: - csv_path = tmp_path / "telemetry.csv" - artifact = tap.AtomicCsv(csv_path, fsync_interval_s=0) - artifact.open() - row = dict.fromkeys(tap.CSV_FIELDS, "") - row["row_kind"] = "memory" - artifact.write(row) - artifact.finalize() - - with csv_path.open(newline="") as f: - rows = list(csv.DictReader(f)) - assert len(rows) == 1 - assert rows[0]["row_kind"] == "memory" - assert not csv_path.with_suffix(".csv.part").exists() - - summary_path = tmp_path / "summary.json" - payload = {"status": "complete"} - from inference_endpoint.utils.atomic_write import atomic_write_bytes - - atomic_write_bytes( - summary_path, (json.dumps(payload, sort_keys=True) + "\n").encode() - ) - assert json.loads(summary_path.read_text()) == payload - - def test_missing_aggregator_is_nonzero_and_still_atomic( - self, tmp_path: Path - ) -> None: - csv_path = tmp_path / "telemetry.csv" - summary_path = tmp_path / "summary.json" - args = tap._build_parser().parse_args( - [ - "--root-pid", - str(2**31 - 1), - "--csv", - str(csv_path), - "--summary", - str(summary_path), - "--discover-timeout-s", - "0", - ] - ) - - exit_code, summary = tap.run(args) - - assert exit_code == 2 - assert summary["status"] == "aggregator_not_found" - assert summary["telemetry_capture_valid"] is False - assert "aggregator_not_found" in summary["telemetry_capture_failures"] - assert csv_path.is_file() - assert summary_path.is_file() - assert not csv_path.with_suffix(".csv.part").exists() - - def test_end_to_end_discovers_and_taps_metrics_pub( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - socket_dir = tmp_path / "sockets" - socket_dir.mkdir() - socket_name = "metrics_test" - child_code = """ -import sys -import time -import zmq -from inference_endpoint.async_utils.services.metrics_aggregator.snapshot import ( - CounterStat, MetricsSnapshot, MetricsSnapshotCodec, SessionState, -) - -args = sys.argv[1:] -socket_dir = args[args.index("--socket-dir") + 1] -socket_name = args[args.index("--metrics-socket") + 1] -ctx = zmq.Context() -sock = ctx.socket(zmq.PUB) -sock.setsockopt(zmq.LINGER, 0) -sock.bind(f"ipc://{socket_dir}/{socket_name}") -codec = MetricsSnapshotCodec() -time.sleep(0.5) -for i in range(1, 7): - snap = MetricsSnapshot( - counter=i, - timestamp_ns=i, - state=SessionState.LIVE, - n_pending_tasks=i, - metrics=[ - CounterStat("total_samples_issued", i), - CounterStat("total_samples_completed", i), - CounterStat("total_samples_failed", 0), - ], - ) - topic, payload = codec.encode(snap) - sock.send(topic + payload) - time.sleep(0.15) -sock.close(0) -ctx.term() -""" - child = subprocess.Popen( - [ - sys.executable, - "-c", - child_code, - tap.AGGREGATOR_MODULE, - "--socket-dir", - str(socket_dir), - "--metrics-socket", - socket_name, - ] - ) - observation = tap.MemoryObservation( - aggregator_alive=True, - rss_kib=100, - hwm_kib=200, - cgroup_current_bytes=300, - cgroup_peak_bytes=400, - cgroup_max_bytes=500, - cgroup_oom=0, - cgroup_oom_kill=0, - mem_available_kib=600, - mem_total_kib=700, - tmpfs_event_files=0, - tmpfs_events_bytes=0, - ) - monkeypatch.setattr(tap, "sample_memory", lambda *args, **kwargs: observation) - csv_path = tmp_path / "telemetry.csv" - summary_path = tmp_path / "summary.json" - args = tap._build_parser().parse_args( - [ - "--root-pid", - str(os.getpid()), - "--csv", - str(csv_path), - "--summary", - str(summary_path), - "--discover-timeout-s", - "5", - "--discover-poll-s", - "0.02", - "--sample-interval-s", - "0.05", - "--poll-timeout-ms", - "20", - "--post-aggregator-exit-s", - "0.1", - "--fsync-interval-s", - "0", - ] - ) - try: - exit_code, summary = tap.run(args) - finally: - child.wait(timeout=5) - - assert exit_code == 0 - assert summary["status"] == "aggregator_exited" - assert summary["telemetry_capture_valid"] is True - assert summary["snapshots_received"] >= 2 - assert summary["published_pending_high_water"] >= 2 - assert summary["aggregator_reported_hwm_high_water_kib"] == 200 - assert summary["cgroup_memory_current_high_water_bytes"] == 300 - assert summary["cgroup_memory_peak_high_water_bytes"] == 400 - assert csv_path.is_file() - assert summary_path.is_file() - with csv_path.open(newline="") as handle: - rows = list(csv.DictReader(handle)) - assert rows[-1]["row_kind"] == "terminal_memory" From b45d776fbc91520d21eab2c611f42bd7a44be9e1 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Fri, 14 Aug 2026 00:36:46 -0700 Subject: [PATCH 07/45] refactor(watchdog): extract _RunWatchdog beside _PerfPhaseTimeout One object owns the whole-run deadline: timer handle, fired flag, and the late-bound session, replacing the nonlocal flag + mutable-holder closure threaded through _run_benchmark_async. Behavior unchanged. --- .../commands/benchmark/execute.py | 102 ++++++++++-------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index fc2739141..f47a8c44a 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -710,6 +710,56 @@ def cancel(self) -> None: self._handle = None +class _RunWatchdog: + """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. + + Armed before the metrics pipeline starts (so service-launch and + endpoint-connect stalls are bounded) and kept armed through the metrics + drain (so a stuck aggregator drain is bounded too). On fire: stop the + session first — it short-circuits its drain and publishes ENDED promptly, + so the event logger flushes and the aggregator records the buffered + tokenizer-drain samples — then SIGTERM the aggregator, whose handler + writes the INTERRUPTED final snapshot (``publish_final`` is first-wins, + so INTERRUPTED stays authoritative). ``run_benchmark`` raises + ``ExecutionError`` after finalization whenever ``fired`` is set, so a + timed-out run always fails loudly even if a still-draining aggregator + finalized COMPLETE first. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + deadline: float | None, + pipe: MetricsPipeline, + ) -> None: + self.fired = False + self._session: BenchmarkSession | None = None + self._pipe = pipe + self._handle = ( + loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) + if deadline is not None + else None + ) + + def bind_session(self, session: BenchmarkSession) -> None: + """Late-bind the session: it is created after the timer is armed.""" + self._session = session + + def _fire(self) -> None: + self.fired = True + logger.error( + "Run timeout reached; aborting run — report will be marked " "INTERRUPTED." + ) + if self._session is not None: + self._session.stop() + self._pipe.terminate_metrics_aggregator() + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None + + async def _create_issuer( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop ) -> tuple[HttpClientSampleIssuer, HTTPEndpointClient]: @@ -842,44 +892,7 @@ async def _run_benchmark_async( # idempotent, so the clean-path shutdown below is a harmless second call. http_client: HTTPEndpointClient | None = None - # Whole-run watchdog. Armed before the pipeline starts so setup stalls - # (service launch, endpoint connect) are bounded too, and kept armed - # through the metrics drain so run_timeout_s can SIGTERM a stuck - # aggregator drain. Cancelled in the outermost finally. - run_timed_out = False - # The session is created later inside the pipeline scope; bind it through - # a mutable holder so the callback never touches a possibly-unbound local - # (a NameError inside a loop callback is swallowed by the loop's exception - # handler, which would leave the watchdog inert). - session_ref: list[BenchmarkSession] = [] - run_timeout_s = config.settings.timeouts.run_timeout_s - - def _on_run_timeout() -> None: - nonlocal run_timed_out - run_timed_out = True - logger.error( - "Run timeout (%.1fs) reached; aborting run — report will be " - "marked INTERRUPTED.", - run_timeout_s, - ) - # Stop the session first: it short-circuits _drain_inflight and - # run()'s finally publishes ENDED promptly, so the aggregator still - # records the buffered tokenizer-drain samples. Then SIGTERM the - # aggregator: its handler writes the INTERRUPTED final snapshot - # (publish_final is first-wins, so INTERRUPTED stays authoritative; - # even if a still-draining aggregator finalizes as COMPLETE first, - # run_benchmark raises on run_timed_out, so a timed-out run always - # fails loudly). Targeted (not all services): the event logger - # flushes on ENDED, which session.stop() still delivers. - if session_ref: - session_ref[0].stop() - pipe.terminate_metrics_aggregator() - - run_watchdog = ( - loop.call_later(max(0.0, deadline - time.monotonic()), _on_run_timeout) - if deadline is not None - else None - ) + watchdog = _RunWatchdog(loop, deadline, pipe) try: tmpfs_dir.mkdir(parents=True, exist_ok=True) @@ -917,7 +930,7 @@ def _on_run_timeout() -> None: on_sample_complete=on_sample_complete, session_id=session_id, ) - session_ref.append(session) + watchdog.bind_session(session) phases = _build_phases(ctx, perf_strategy=agentic_inference_strategy) max_duration_ms = ( @@ -957,7 +970,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: loop.add_signal_handler(signal.SIGINT, session.stop) try: - if run_timed_out: + if watchdog.fired: # Deadline elapsed during setup — never start issuing # load after it. Run the already-stopped session so # STARTED/ENDED still flow: the event logger exits only @@ -972,7 +985,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: ) session_completed_normally = True except Exception as e: - if run_timed_out: + if watchdog.fired: # The watchdog already aborted the run; a teardown race # can surface here as a generic exception. Fall through # with an empty session result so finalize still writes @@ -998,7 +1011,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: # Unifies the clean phase-end path and the abort path — both # reach this block. A watchdog abort counts as an abort even # when session.run returned normally after session.stop(). - profiler.stop(session_completed_normally and not run_timed_out) + profiler.stop(session_completed_normally and not watchdog.fired) # Graceful drain runs on both the clean-finish and session- # failure paths (BenchmarkSession.run publishes ENDED in its own # finally, so a failed run still has a terminal snapshot worth @@ -1052,8 +1065,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: ) raise finally: - if run_watchdog is not None: - run_watchdog.cancel() + watchdog.cancel() return BenchmarkResult( session=result, @@ -1061,7 +1073,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: report=report, tmpfs_dir=tmpfs_dir, profiling=profiler.payload(), - run_timed_out=run_timed_out, + run_timed_out=watchdog.fired, ) From 2b644ad6dd0bc1c5478bc48cca550ec9ced65435 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Fri, 14 Aug 2026 00:36:49 -0700 Subject: [PATCH 08/45] docs: current-state pass over the timeout surface Fix references left behind by the consolidation: renamed drain knob in session.py docstring, argv-vs-schema 0-sentinel wording in the aggregator snapshot/help text, config module pointers after the schema split, the from-config flag surface in CLI_QUICK_REFERENCE, regenerate-templates trigger lists, the config DESIGN nested-model table, and the compliance plan's duration-floor note. schema.py re-exports HTTPClientConfig again. --- AGENTS.md | 4 ++-- docs/CLI_DESIGN.md | 2 +- docs/CLI_QUICK_REFERENCE.md | 9 ++++----- docs/DEVELOPMENT.md | 2 +- .../services/metrics_aggregator/DESIGN.md | 3 ++- docs/compliance_audit_plan.md | 14 ++++++-------- docs/config/DESIGN.md | 19 ++++++++++--------- .../services/metrics_aggregator/__main__.py | 4 ++-- .../services/metrics_aggregator/snapshot.py | 2 +- src/inference_endpoint/config/schema.py | 2 ++ .../load_generator/session.py | 2 +- .../integration/commands/test_run_timeout.py | 6 +++--- tests/unit/config/test_timeouts.py | 11 +++++------ 13 files changed, 40 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ffe50dedd..7c21a474d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. CLI is auto-generated from `config/schema.py` Pydantic models via cyclopts. Fields annotated with `cyclopts.Parameter(alias="--flag")` get flat shorthands; all other fields get auto-generated dotted flags (kebab-case). - **CLI mode** (`offline`/`online`): cyclopts constructs `OfflineBenchmarkConfig`/`OnlineBenchmarkConfig` (subclasses in `config/schema.py`) directly from CLI args. Type locked via `Literal`. `--dataset` is repeatable with TOML-style format `[perf|acc:][,key=value...]` (e.g. `--dataset data.csv,samples=500,parser.prompt=article`). Full accuracy support via `accuracy_config.eval_method=pass_at_1` etc. -- **YAML mode** (`from-config`): `BenchmarkConfig.from_yaml_file()` loads YAML, resolves env vars, and auto-selects the right subclass via Pydantic discriminated union. Optional `--timeout` (maps to `settings.timeouts.run_timeout_s`)/`--mode` overrides via `config.with_updates()`. +- **YAML mode** (`from-config`): `BenchmarkConfig.from_yaml_file()` loads YAML, resolves env vars, and auto-selects the right subclass via Pydantic discriminated union. Optional `--timeout` override maps to `settings.timeouts.run_timeout_s` via `config.with_updates()`; `--mode` selects the `TestMode` passed to the runner. - **eval**: Not yet implemented (raises `CLIError` with a tracking issue link) ### Config Construction & Validation @@ -315,7 +315,7 @@ All of these run automatically on commit: - `mypy` type checking - `prettier` for YAML/JSON/Markdown - License header enforcement -- `regenerate-templates`: auto-regenerates YAML config templates from schema defaults when `schema.py`, `config.py`, or `regenerate_templates.py` change +- `regenerate-templates`: auto-regenerates YAML config templates from schema defaults when any config schema module (`schema|audit|model_params|datasets|settings|timeouts`.py), `endpoint_client/config.py`, or `regenerate_templates.py` changes **IMPORTANT: Always run `pre-commit run --all-files` before every commit.** Hooks may modify files (prettier, ruff-format, license headers). If files are modified, stage the changes and commit once. Never commit without running pre-commit first. diff --git a/docs/CLI_DESIGN.md b/docs/CLI_DESIGN.md index 474c81fb9..9b9807a1f 100644 --- a/docs/CLI_DESIGN.md +++ b/docs/CLI_DESIGN.md @@ -67,7 +67,7 @@ Both paths produce the **same subclass with the same defaults**. A YAML file wit 2. **Auto-selects subclass.** `type: "offline"` → `OfflineBenchmarkConfig`, `type: "online"` → `OnlineBenchmarkConfig`, others → base `BenchmarkConfig`. -3. **Optional CLI overrides.** `--timeout` and `--mode` applied via `config.with_updates(...)` which re-runs validators. +3. **Optional CLI overrides.** `--timeout` maps into `settings.timeouts.run_timeout_s` via `with_updates(...)` (re-runs validators); `--mode` selects the `TestMode` passed to the runner and never touches the config object. ### Why subclasses? diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index b09f2d80d..5c6a4dbf7 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -102,9 +102,8 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. - `--endpoint-config.api-key --api-key` - API authentication - `--endpoint-config.api-type --api-type` - API type: openai/sglang (default: openai) - `--report-dir` - Report output directory - Note: applies to CLI-driven `benchmark offline` / `benchmark online`; `benchmark from-config` - does not expose a CLI override for `report_dir`. Set it in the YAML only if you need to control - the output location; otherwise a default report directory is used. + Note: `benchmark from-config` also accepts `--report-dir` as an override of the YAML value; + when neither is set a default report directory is used. - `--timeout` - Whole-run watchdog in seconds (off by default). If it fires, the run is aborted, the report is marked INTERRUPTED, and the process exits non-zero. - `--enable-cpu-affinity / --no-cpu-affinity` - NUMA-aware CPU pinning (default: true) - `--no-early-stopping` - opt out of the MLPerf early-stopping percentile estimates in `result_summary.json` (default: on; see [early_stopping.md](early_stopping.md)) @@ -307,8 +306,8 @@ inference-endpoint init submission # 3. Run (YAML mode) inference-endpoint benchmark from-config \ --config submission_template.yaml -# Note: from-config only accepts --config, --timeout, and --mode via CLI. -# Set report_dir in the YAML if you need a specific output location. +# from-config accepts --config, --timeout, --mode, --accuracy-only, and +# --report-dir; everything else comes from the YAML. ``` ### Validate First diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c9dd2a3c8..aef0f5c9d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -201,7 +201,7 @@ docs/short-description ## YAML Config Templates -Config templates in `src/inference_endpoint/config/templates/` are auto-generated from schema defaults. When you change `config/schema.py`, regenerate them: +Config templates in `src/inference_endpoint/config/templates/` are auto-generated from schema defaults. When you change any config schema module (`config/schema.py` and its sibling domain modules), regenerate them: ```bash uv run python scripts/regenerate_templates.py diff --git a/docs/async_utils/services/metrics_aggregator/DESIGN.md b/docs/async_utils/services/metrics_aggregator/DESIGN.md index 951cd9b24..48f0ed33f 100644 --- a/docs/async_utils/services/metrics_aggregator/DESIGN.md +++ b/docs/async_utils/services/metrics_aggregator/DESIGN.md @@ -122,7 +122,8 @@ COMPLETE event ─► trigger.fire ─► queue.enqueue(text, on_count) [ `--drain-timeout` and `--tokenizer-workers` have service-side defaults (`0` and `2`) so the service is launchable by hand without tuning knobs, but -`config/schema.py` is the single source of truth: the benchmark always +the config schema is the single source of truth (`settings.timeouts.metrics_drain_timeout_s` +in `config/timeouts.py`, `settings.metrics_tokenizer_workers` in `config/settings.py`): the benchmark always forwards the schema values (`--metrics-drain-timeout`, `--metrics-tokenizer-workers`), overriding these defaults in normal runs. diff --git a/docs/compliance_audit_plan.md b/docs/compliance_audit_plan.md index 4f0625b44..fa24f09cc 100644 --- a/docs/compliance_audit_plan.md +++ b/docs/compliance_audit_plan.md @@ -523,14 +523,12 @@ Two scenarios must be covered: **Offline** (`max_throughput`) and **SingleStream > catches a crashed run — but the examples default to equal for the clearest, least-contentious > comparison. -> **`min_duration` is not a duration floor (current limitation).** The load-generator stop -> check (`session.py`) halts a phase on **sample count** or **`max_duration_ms`** only; -> `min_duration_ms` merely _derives_ a count when no explicit count is set. Because TEST04 -> drives an explicit `samples` count, each phase stops at `samples` and `min_duration_ms` is -> **not** honored as a "run for at least 10 minutes" floor. MLCommons' 10-minute compliance -> minimum therefore is **not** enforced today; combining a count floor with a duration floor -> ("AND-semantics") is future work. Set `samples` large enough that each phase reaches a -> stable throughput on its own. +> **No duration floor (current limitation).** Runs are count-driven: the load-generator stop +> check (`session.py`) halts a phase on **sample count** or **`runtime.max_duration_ms`** +> only, and TEST04 drives explicit `samples` / `audit_samples` counts. MLCommons' 10-minute +> compliance minimum therefore is **not** enforced today; combining a count floor with a +> duration floor ("AND-semantics") is future work. Set `samples` large enough that each phase +> reaches a stable throughput on its own. Both scenarios ship as committed configs (see also [`compliance/audit_test/README.md`](../src/inference_endpoint/compliance/audit_test/README.md)): diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index b8208c909..e302b2f1d 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -45,13 +45,14 @@ the benchmark `type` and `endpoint_config`. Key nested models: -| Model | Purpose | -| ---------------- | --------------------------------------------------- | -| `LoadPattern` | Pattern type + parameters (target QPS, concurrency) | -| `RuntimeConfig` | Duration, sample count, RNG seeds | -| `ClientSettings` | Worker count and HTTP client settings | -| `EndpointConfig` | Endpoint URLs, API key | -| `Dataset` | Dataset path, type (performance / accuracy) | +| Model | Purpose | +| ------------------ | ----------------------------------------------------------- | +| `LoadPattern` | Pattern type + parameters (target QPS, concurrency) | +| `RuntimeConfig` | Sample count, perf-phase cap (`max_duration_ms`), RNG seeds | +| `Timeouts` | All global waits/deadlines (`settings.timeouts`) | +| `HTTPClientConfig` | Worker count and HTTP client settings (`settings.client`) | +| `EndpointConfig` | Endpoint URLs, API key | +| `Dataset` | Dataset path, type (performance / accuracy) | ### `RuntimeSettings` (frozen dataclass) @@ -145,8 +146,8 @@ to the report output. | Consumer | Usage | | ------------------------------- | ------------------------------------------------------------ | | `load_generator/session.py` | Receives `RuntimeSettings` at construction | -| `load_generator/scheduler.py` | Reads `load_pattern`, `n_samples_to_issue`, RNG seeds | +| `load_generator/strategy.py` | Reads `load_pattern`, `n_samples_to_issue`, RNG seeds | | `endpoint_client/config.py` | Reads `api_type`, `num_workers`, streaming mode | -| `metrics/reporter.py` | Reads `reported_metrics`, duration bounds | +| `metrics/report.py` | Reads `reported_metrics`, duration bounds | | `commands/benchmark/cli.py` | Defines benchmark subcommands and resolves CLI vs YAML input | | `commands/benchmark/execute.py` | Runs the benchmark lifecycle from resolved configuration | diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py index d65bf3ab3..4320c9fa0 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py @@ -145,7 +145,7 @@ async def main() -> None: "Wall-clock budget (seconds) to finish tokenizing buffered samples " "after ENDED before the aggregator emits the final snapshot with " "n_pending_tasks > 0 (0 = wait indefinitely, the default; the " - "benchmark forwards the schema default, see config/schema.py). " + "benchmark forwards the schema default, see config/timeouts.py). " "Increase for very large datasets where the end-of-run tokenize " "batch is big." ), @@ -176,7 +176,7 @@ async def main() -> None: "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT " "(0 = no mid-run tokenization, everything defers to the " "end-of-run drain; the benchmark forwards the schema default, " - "see config/schema.py). The drain always uses the auto-sized " + "see config/settings.py). The drain always uses the auto-sized " "sharded pool — one worker process per 8-core block." ), ) diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py index 5c5691d2b..8284bdf0a 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py @@ -45,7 +45,7 @@ class SessionState(str, Enum): LIVE → run in progress; tick task publishing live HDR-derived stats. DRAINING → ``SessionEventType.ENDED`` has been received; the aggregator is tokenizing the buffered samples (bounded by the - ``--drain-timeout`` budget — schema default 0 = unlimited). Tick task + ``--drain-timeout`` budget — argv 0 = unlimited; the schema knob ``settings.timeouts.metrics_drain_timeout_s`` uses None, converted at the argv boundary). Tick task continues at this stage, still HDR-derived; no new events will arrive. COMPLETE → terminal clean state. The ``publish_final()`` snapshot diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 2c1046eb7..10a31eeb1 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -51,6 +51,7 @@ ) from ..core.types import APIType +from ..endpoint_client.config import HTTPClientConfig from ..exceptions import CLIError from ..utils import WithUpdatesMixin from .audit import AuditConfig, AuditTestId, OutputCachingTestConfig @@ -97,6 +98,7 @@ "EarlyStoppingConfig", "EndpointConfig", "EvalMethod", + "HTTPClientConfig", "LoadPattern", "LoadPatternType", "ModelParams", diff --git a/src/inference_endpoint/load_generator/session.py b/src/inference_endpoint/load_generator/session.py index 319a8be85..3aa98d64f 100644 --- a/src/inference_endpoint/load_generator/session.py +++ b/src/inference_endpoint/load_generator/session.py @@ -404,7 +404,7 @@ def stop_current_phase(self) -> None: Also sets the drain event: if the cap fires while the phase is already inside its ``_drain_inflight`` wait (strategy task finished), cancelling - the task is a no-op, so an unbounded (``performance_timeout_s: null``) + the task is a no-op, so an unbounded (``performance_drain_timeout_s: null``) drain would otherwise hang forever on a stuck in-flight response. """ self._current_phase_stopped = True diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index 96835c0c4..86dd0a358 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -16,9 +16,9 @@ """Whole-run watchdog (settings.timeouts.run_timeout_s) integration tests. Locking invariant: a fired run watchdog must never produce a COMPLETE -report. The watchdog SIGTERMs the metrics aggregator (whose handler writes -an INTERRUPTED final snapshot) before stopping the session, and -``run_benchmark`` exits non-zero via ``ExecutionError``. +report. The watchdog stops the session (ENDED still flows) and then +SIGTERMs the metrics aggregator, whose handler writes an INTERRUPTED +final snapshot; ``run_benchmark`` exits non-zero via ``ExecutionError``. """ import json diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index ef958a3f1..3abf8cd88 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -13,11 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for the consolidated ``settings.timeouts`` block (Timeouts model), -the reworked ``runtime.max_duration_ms`` knob, and the hard removal of the -pre-consolidation config surface (``settings.drain``, top-level ``timeout``, -``settings.service_ready_timeout_s``, ``runtime.min_duration_ms``, and the -``settings.client.worker_*`` knobs).""" +"""Tests for the ``settings.timeouts`` block (Timeouts model), the +``runtime.max_duration_ms`` perf-phase cap, and the rejection of config +keys that do not exist (``settings.drain``, top-level ``timeout``, +``settings.service_ready_timeout_s``, ``runtime.min_duration_ms``).""" import random @@ -110,7 +109,7 @@ def test_extra_fields_rejected(self): class TestDeletedConfigSurface: - """Hard cutover: the pre-consolidation keys must error, not silently pass.""" + """Removed config keys must error via extra=forbid, not silently pass.""" @pytest.mark.unit def test_settings_drain_block_rejected(self): From c243a81e55f0c2ac7d8e1e20779611cc7c275760 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Fri, 14 Aug 2026 22:01:22 -0700 Subject: [PATCH 09/45] style(config): drop extra blank line left by rebase conflict resolution --- src/inference_endpoint/config/schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 10a31eeb1..4de5a3864 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -154,7 +154,6 @@ class TestType(str, Enum): SUBMISSION = "submission" - class EndpointConfig(BaseModel): """Endpoint connection configuration. From d4c4e2ecfb482281680a7006160c593a0878caf5 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 13:05:53 -0700 Subject: [PATCH 10/45] refactor(config): defer the schema.py split to a follow-up MR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer discussion on #409 settled on keeping this MR about the timeout-knob consolidation; the schema.py monolith split (whatever its final shape) moves to a dedicated follow-up MR. schema.py stays one module — the declared YAML/CLI surface, distinct from the resolved run plan in runtime_settings.py — now containing the settings: subtree (incl. the frozen Timeouts at settings.timeouts), the workload definition (model_params:/datasets:), the root aggregates, and the audit: block, in that order with section banners. Kept from the split work, split-independent: - the regenerate-templates pre-commit hook matches every config/*.py instead of an enumerated filename list, so a renamed module cannot silently skip template regeneration. - enums.py stays dissolved: each enum lives beside its owner. - a stale 3-tuple _load_datasets mock left by the rebase onto main (#437 changed the return arity to 2) stays fixed. --- .pre-commit-config.yaml | 2 +- AGENTS.md | 9 +- .../services/metrics_aggregator/DESIGN.md | 2 +- src/inference_endpoint/config/audit.py | 84 -- src/inference_endpoint/config/datasets.py | 314 ----- src/inference_endpoint/config/model_params.py | 187 --- src/inference_endpoint/config/schema.py | 1007 +++++++++++++++-- src/inference_endpoint/config/settings.py | 368 ------ .../templates/concurrency_template_full.yaml | 2 +- .../templates/offline_template_full.yaml | 2 +- .../templates/online_template_full.yaml | 2 +- src/inference_endpoint/config/timeouts.py | 134 --- .../integration/commands/test_run_timeout.py | 2 +- tests/unit/commands/test_benchmark.py | 4 +- tests/unit/config/test_timeouts.py | 2 +- 15 files changed, 941 insertions(+), 1180 deletions(-) delete mode 100644 src/inference_endpoint/config/audit.py delete mode 100644 src/inference_endpoint/config/datasets.py delete mode 100644 src/inference_endpoint/config/model_params.py delete mode 100644 src/inference_endpoint/config/settings.py delete mode 100644 src/inference_endpoint/config/timeouts.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d331509c..e25d9c562 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: entry: uv run --no-sync python scripts/regenerate_templates.py language: system pass_filenames: false - files: ^(src/inference_endpoint/config/((schema|audit|model_params|datasets|settings|timeouts)\.py|templates/.*)|src/inference_endpoint/endpoint_client/config\.py|scripts/regenerate_templates\.py)$ + files: ^(src/inference_endpoint/config/([^/]+\.py|templates/.*)|src/inference_endpoint/endpoint_client/config\.py|scripts/regenerate_templates\.py)$ - id: add-license-header name: Add license headers diff --git a/AGENTS.md b/AGENTS.md index 7c21a474d..6855d1378 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Dataset Manager --> Load Generator --> Endpoint Client --> External Endpoint | **Dataset Manager** | `src/inference_endpoint/dataset_manager/` | Loads JSONL, HuggingFace, CSV, JSON, Parquet datasets. `Dataset` base class with `load_sample()`/`num_samples()` interface | | **Metrics Aggregator** | `src/inference_endpoint/async_utils/services/metrics_aggregator/` | Subprocess. Subscribes to events, aggregates per-sample metrics into a `MetricsRegistry` (counters + HDR-histogram series + raw values), publishes `MetricsSnapshot` over IPC PUB at a configurable cadence (`SessionState`: `INITIALIZE` → `LIVE` → `DRAINING` → {`COMPLETE` \| `INTERRUPTED`}). Final snapshot is atomically written to `final_snapshot.json` as the **primary** Report source; the terminal pub/sub frame is a TUI "run finished" signal only. | | **Report** | `src/inference_endpoint/metrics/report.py` | `Report.from_snapshot(dict)` — pure-function builder consuming the dict form (`snapshot_to_dict`). Reads `final_snapshot.json` directly via `json.loads` (no Struct decode). Plumbs `complete = (state == "complete" and n_pending_tasks == 0)`; renders an explicit warning for `INTERRUPTED` runs. | -| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema split into focused modules (`schema.py` = BenchmarkConfig + EndpointConfig + root TestType/TestMode + re-export hub; `audit.py`, `model_params.py`, `datasets.py`, `settings.py` — each domain owns its models AND enums), `Timeouts` (`config/timeouts.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`; client worker-lifecycle timeouts stay on `settings.client`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | +| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema (`schema.py`) — the declared user surface, distinct from the resolved `runtime_settings.py`; `Timeouts` (`config/schema.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`; client worker-lifecycle timeouts stay on `settings.client`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | | **CLI** | `src/inference_endpoint/main.py`, `commands/benchmark/cli.py` | cyclopts-based, auto-generated from `schema.py` and `HTTPClientConfig` Pydantic models. Flat shorthands via `cyclopts.Parameter(alias=...)` | | **Async Utils** | `src/inference_endpoint/async_utils/` | `LoopManager` (uvloop + eager_task_factory), ZMQ transport layer, generic `MessageCodec[T]`-parametrized pub/sub, event publisher | | **OpenAI/SGLang** | `src/inference_endpoint/openai/`, `sglang/` | Protocol adapters and response accumulators for different API formats. `openai_completions` adapter (`completions_adapter.py`) sends pre-tokenized token IDs to `/v1/completions`, bypassing the server chat template — required for gpt-oss-120b on vLLM. `sglang` adapter sends to `/generate` via `input_ids`. Both apply `Harmonize()` client-side. | @@ -244,12 +244,7 @@ src/inference_endpoint/ │ ├── early_stopping.py # MLPerf LoadGen early-stopping percentile estimates (pure math; see docs/early_stopping.md) │ └── results_plots.py # Standardized run-artifact plots (matplotlib-guarded); CLI: scripts/plot_results.py ├── config/ -│ ├── schema.py # BenchmarkConfig + EndpointConfig + TestType/TestMode; re-export hub for the schema surface -│ ├── audit.py # Audit config models (audit: YAML block) -│ ├── model_params.py # ModelParams, OSLDistribution, SubmissionReference -│ ├── datasets.py # Dataset, AccuracyConfig, AgenticInferenceConfig -│ ├── settings.py # Settings + Runtime/LoadPattern/Warmup/Profiling/EarlyStopping configs -│ ├── timeouts.py # Timeouts — all give-up deadlines (settings.timeouts) +│ ├── schema.py # Declared YAML/CLI surface (pydantic): settings incl. Timeouts, workload (model_params/datasets), roots + audit: block │ ├── runtime_settings.py # RuntimeSettings + SampleOrderSpec dataclasses │ ├── ruleset_base.py # BenchmarkSuiteRuleset base │ ├── ruleset_registry.py # Ruleset registry diff --git a/docs/async_utils/services/metrics_aggregator/DESIGN.md b/docs/async_utils/services/metrics_aggregator/DESIGN.md index 48f0ed33f..58c9eb2f9 100644 --- a/docs/async_utils/services/metrics_aggregator/DESIGN.md +++ b/docs/async_utils/services/metrics_aggregator/DESIGN.md @@ -123,7 +123,7 @@ COMPLETE event ─► trigger.fire ─► queue.enqueue(text, on_count) [ `--drain-timeout` and `--tokenizer-workers` have service-side defaults (`0` and `2`) so the service is launchable by hand without tuning knobs, but the config schema is the single source of truth (`settings.timeouts.metrics_drain_timeout_s` -in `config/timeouts.py`, `settings.metrics_tokenizer_workers` in `config/settings.py`): the benchmark always +in `config/schema/settings.py`, `settings.metrics_tokenizer_workers` in `config/schema.py`): the benchmark always forwards the schema values (`--metrics-drain-timeout`, `--metrics-tokenizer-workers`), overriding these defaults in normal runs. diff --git a/src/inference_endpoint/config/audit.py b/src/inference_endpoint/config/audit.py deleted file mode 100644 index 0af28d38a..000000000 --- a/src/inference_endpoint/config/audit.py +++ /dev/null @@ -1,84 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Compliance audit configuration. - -Split criterion: one module per config domain; the audit test registry and its -per-test config models live here. ``config/schema.py`` re-exports the public -surface. -""" - -from __future__ import annotations - -from enum import Enum -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class AuditTestId(str, Enum): - """Registered compliance audit test identifiers.""" - - # Output-caching audit — MLPerf TEST04 (duplicate-query caching detection). - OUTPUT_CACHING_TEST = "output_caching_test" - - -class OutputCachingTestConfig(BaseModel): - """Configuration for the output-caching audit (MLPerf TEST04). - - The output-caching test runs two back-to-back phases — a reference run of - distinct samples and an audit run that repeats one fixed sample — then - checks that the audit QPS does not exceed the reference QPS by more than - ``threshold``. A large speedup indicates the SUT is caching responses. - - samples: reference-phase query count (required — an explicit count keeps - the per-phase completion check meaningful; a duration-driven phase has - no independent target to validate completion against) - audit_samples: audit-phase query count (None → equals samples) - sample_index: which dataset row is repeated (MLCommons performance_issue_same_index) - threshold: tolerance shared by both pass checks — each phase must complete - ≥ requested * (1 - threshold), and audit_qps must stay < ref_qps * (1 + threshold) - """ - - model_config = ConfigDict(frozen=True, extra="forbid") - - test: Literal[AuditTestId.OUTPUT_CACHING_TEST] - only: bool = Field( - False, - description="Run only the audit — skip the main benchmark (upstream-style standalone TEST04)", - ) - samples: int = Field(..., ge=1, description="Reference phase query count") - audit_samples: int | None = Field( - None, ge=1, description="Audit phase query count (default: equals samples)" - ) - sample_index: int = Field( - 0, ge=0, description="Dataset row index repeated in the audit phase" - ) - threshold: float = Field( - 0.10, - gt=0, - lt=1, - description=( - "Tolerance for both checks: each phase must complete " - "≥ requested * (1 - threshold), and audit_qps must stay " - "< ref_qps * (1 + threshold)" - ), - ) - - -# Single member today; becomes -# Annotated[OutputCachingTestConfig | ..., Field(discriminator="test")] -# when additional audit tests are added. -AuditConfig = OutputCachingTestConfig diff --git a/src/inference_endpoint/config/datasets.py b/src/inference_endpoint/config/datasets.py deleted file mode 100644 index da368910d..000000000 --- a/src/inference_endpoint/config/datasets.py +++ /dev/null @@ -1,314 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Dataset configuration models. - -Split criterion: one module per config domain; the dataset models and their -generation-config-override merge helpers live here (the override keys' only -consumer is ``Dataset``, so they stay together). ``config/schema.py`` -re-exports the public surface. -""" - -from __future__ import annotations - -from enum import Enum -from pathlib import Path -from typing import Annotated, Any, Self - -import cyclopts -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from .model_params import ModelParams - - -def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: - """Recursively merge ``override`` into ``base`` and return the result. - - For overlapping keys whose values are both dicts, recurse; otherwise the - override value wins. Mutates a *copy* — callers can safely pass model_dump() - output. Used by ``Dataset.effective_generation_config`` so a sparse nested - override (e.g. ``{osl_distribution: {max: 512}}``) preserves siblings. - """ - out = dict(base) - for k, v in override.items(): - if isinstance(v, dict) and isinstance(out.get(k), dict): - out[k] = _deep_merge(out[k], v) - else: - out[k] = v - return out - - -# ModelParams fields that drive the single global tokenizer / MetricsAggregator -# (launched once from top-level model_params), so a per-dataset override would -# desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected -# as generation_config_override keys — they are per-run/identity, not per-dataset. -_METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"}) - - -class DatasetType(str, Enum): - """Dataset purpose type.""" - - PERFORMANCE = "performance" - ACCURACY = "accuracy" - - -class EvalMethod(str, Enum): - """Evaluation methods for accuracy testing.""" - - EXACT_MATCH = "exact_match" - CONTAINS = "contains" - JUDGE = "judge" - - -class ScorerMethod(str, Enum): - """Registered scorer methods for accuracy evaluation.""" - - PASS_AT_1 = "pass_at_1" - STRING_MATCH = "string_match" - ROUGE = "rouge" - CODE_BENCH = "code_bench_scorer" - SHOPIFY_CATEGORY_F1 = "shopify_category_f1" - AGENTIC_INFERENCE_INLINE = "agentic_inference_inline" - VBENCH = "vbench" - BFCL_V4 = "bfcl_v4" - LEGACY_MLPERF_DEEPSEEK_R1 = "legacy_mlperf_deepseek_r1" - SWE_BENCH = "swe_bench_scorer" - - -class AgenticInferenceConfig(BaseModel): - """Agentic inference conversation configuration. - - Configuration for benchmarking conversational AI workloads with turn sequencing. - Enables testing agentic inference conversations where each turn depends on previous responses. - Presence of this block in the dataset config enables agentic inference mode. - - Attributes: - turn_timeout_s: Deadline between issuing a turn and receiving its - response. A timeout aborts that turn and all remaining client - turns of the same conversation because subsequent turns depend - on the timed-out response. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - turn_timeout_s: float = Field( - default=86400.0, - gt=0, - description=( - "Per-turn timeout in seconds. A timeout aborts that turn and all " - "remaining turns in the same conversation." - ), - ) - enable_salt: bool = Field( - False, - description=( - "Add deterministic salt markers before and after the system prompt " - "to prevent KV cache reuse across trajectories in agentic inference setting." - ), - ) - inject_tool_delay: bool = Field( - False, - description=( - "Pause for a predefined duration between turns. Duration is defined " - "in dataset." - ), - ) - routing_headers: tuple[str, ...] = Field( - default=("X-Session-ID",), - description=( - "HTTP header names populated with the conversation ID on every " - "agentic request." - ), - ) - num_trajectories_to_issue: int | None = Field( - default=None, - gt=0, - description=( - "Number of conversation trajectories to start. Defaults to one pass " - "over the dataset; values above the dataset size repeat trajectories " - "with unique logical conversation ids." - ), - ) - stop_issuing_on_first_user_complete: bool = Field( - False, - description=( - "When performance tracking stops because the first concurrency slot " - "has no next trajectory left to assign, also stop issuing future " - "turns. If false, replay continues outside the performance window " - "for accuracy/log coverage." - ), - ) - - -class AccuracyConfig(BaseModel): - """Accuracy configuration. - - eval_method: Scorer to use (see ScorerMethod enum for options). - ground_truth: Column in the dataset containing ground truth. Defaults to "ground_truth". - extractor: Post-processor to extract answers from model output - (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor). - Optional for scorers that declare REQUIRES_EXTRACTOR = False (e.g. vbench). - num_repeats: Number of times to repeat the dataset for evaluation. Defaults to 1. - extras: Free-form keyword args forwarded to the scorer's ``__init__`` — - used for scorer-specific knobs that don't warrant a top-level field - (e.g. ``vbench_project_path``, ``subprocess_timeout_s`` for VBench). - - Example: - accuracy_config: - eval_method: "pass_at_1" - ground_truth: "answer" - extractor: "boxed_math_extractor" - num_repeats: 5 - extras: - vbench_project_path: "/path/to/accuracy" - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - eval_method: ScorerMethod | None = Field(None, description="Scorer method") - ground_truth: str | None = Field(None, description="Ground truth column name") - extractor: str | None = Field( - None, - description="Answer extractor (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor)", - ) - num_repeats: int = Field( - 1, ge=1, description="Repeat dataset N times for evaluation" - ) - extras: dict[str, Any] | None = Field( - None, - description="Free-form scorer kwargs (e.g. vbench_project_path, subprocess_timeout_s)", - ) - - -class Dataset(BaseModel): - """Dataset configuration. - - Name and type have smart defaults: name is auto-derived from path, - type defaults to PERFORMANCE. - - Accepts CLI strings via BeforeValidator on BenchmarkConfig.datasets: - ``[perf|acc:][,key=value...]`` - """ - - model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) - - name: str = Field("", description="Dataset name (auto-derived from path if empty)") - type: DatasetType = Field( - DatasetType.PERFORMANCE, description="Dataset purpose: performance or accuracy" - ) - path: Annotated[ - str | None, cyclopts.Parameter(alias="--dataset", help="Dataset file path") - ] = None - format: str | None = Field(None, description="Dataset format (auto-detected)") - samples: int | None = Field(None, gt=0, description="Number of samples to use") - eval_method: EvalMethod | None = Field( - None, description="Accuracy evaluation method" - ) - parser: dict[str, str] | None = Field( - None, description="Column remapping: {prompt: , system: }" - ) - generate_params: dict[str, Any] | None = Field( - None, description="Dataset-specific parameters passed to the generate() method" - ) - accuracy_config: AccuracyConfig | None = Field( - None, description="Accuracy evaluation settings" - ) - agentic_inference: AgenticInferenceConfig | None = Field( - None, description="Agentic inference conversation configuration" - ) - # Per-dataset generation config is a first-class capability: different - # accuracy datasets legitimately want different generation settings (e.g. - # per-dataset max OSL or top_p, as seen in DS-V4), and dataset-scoping also - # enables per-dataset dynamic OSL distributions. Only generation knobs are - # overridable — per-run/identity fields (`_METRICS_DECOUPLED_OVERRIDE_KEYS`: - # name / streaming / tokenizer_name) drive the single global tokenizer and - # MetricsAggregator, so overriding them per-dataset would desync ISL/OSL/ - # TTFT/TPOT accounting; they are rejected at validation. - # - # TODO(post-mortem): split ModelParams into a per-run ModelIdentity and a - # GenerationConfig, so the override surface is exactly the generation fields - # and identity fields cannot be named here at all. Field/method names use - # "generation_config" to keep that migration mechanical. - # - # Nested dicts (`osl_distribution`, `chat_template_kwargs`) are deep-merged - # so sparse overrides preserve sibling defaults. - generation_config_override: dict[str, Any] | None = Field( - None, - description=( - "Per-dataset overrides for the top-level model_params (sparse — " - "only the fields you want to override). Merged on top of " - "BenchmarkConfig.model_params at dataset-load time. Useful for " - "MLPerf-style runs where accuracy and performance use different " - "output budgets in the same fleet, e.g. " - "generation_config_override: {max_new_tokens: 32768, " - "temperature: 0.0}. NOTE: per-run/identity keys (`name`, " - "`streaming`, `tokenizer_name`) are rejected here — set them on " - "top-level model_params." - ), - ) - - @model_validator(mode="after") - def _auto_derive_name(self) -> Self: - """Derive name from path stem if not explicitly provided.""" - if not self.name and self.path: - object.__setattr__(self, "name", Path(self.path).stem) - return self - - @model_validator(mode="after") - def _validate_generation_config_override(self) -> Self: - """Fail fast on unknown keys and on per-run/identity keys the single - global tokenizer / MetricsAggregator would ignore. Override *values* - are validated at merge time (see ``effective_generation_config``) - because cross-field validation needs the base ``ModelParams`` from - ``BenchmarkConfig``. - """ - if self.generation_config_override: - keys = set(self.generation_config_override) - valid = set(ModelParams.model_fields) - bad = sorted(keys - valid) - if bad: - raise ValueError( - f"Dataset '{self.name}': unknown keys in " - f"generation_config_override: {bad}. " - f"Valid keys: {sorted(valid)}" - ) - decoupled = sorted(keys & _METRICS_DECOUPLED_OVERRIDE_KEYS) - if decoupled: - raise ValueError( - f"Dataset '{self.name}': generation_config_override keys " - f"{decoupled} are not honored per-dataset — the single " - "global tokenizer / metrics aggregator is launched from " - "top-level model_params, so a per-dataset value would " - "desync ISL/OSL/TTFT/TPOT accounting. Set them on " - "top-level model_params instead." - ) - return self - - def effective_generation_config(self, base: ModelParams) -> ModelParams: - """Return base merged with this dataset's generation-config overrides. - - Nested dicts are deep-merged so a sparse nested override preserves - sibling defaults (e.g. ``{osl_distribution: {max: 512}}`` keeps the - base ``type/mean/std/min``). The merged dict is re-validated through - ``ModelParams.model_validate`` so type-invalid scalar overrides (e.g. - ``temperature: 'hot'``) are rejected. Note that this only catches - scalar invalidity — a sparse nested override whose merged result - passes default-validation will not raise (callers that need stricter - nested validation should set ``base`` to an explicit instance). - """ - if not self.generation_config_override: - return base - merged = _deep_merge(base.model_dump(), self.generation_config_override) - return ModelParams.model_validate(merged) diff --git a/src/inference_endpoint/config/model_params.py b/src/inference_endpoint/config/model_params.py deleted file mode 100644 index 1e47e3de3..000000000 --- a/src/inference_endpoint/config/model_params.py +++ /dev/null @@ -1,187 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Model generation parameters and submission reference. - -Split criterion: one module per config domain; the model/generation-parameter -models and the submission reference live here. ``config/schema.py`` re-exports -the public surface. -""" - -from __future__ import annotations - -from enum import Enum -from typing import Annotated, Any, Self - -import cyclopts -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from .ruleset_base import BenchmarkSuiteRuleset - - -def _non_default_completion_controls(mp: ModelParams) -> list[str]: - """Completion-only ModelParams controls set to a non-default value. - - ``min_new_tokens``/``skip_special_tokens`` are only honored by the - ``openai_completions`` adapter; ``BenchmarkConfig`` rejects them for other - ``api_type``s. Shared by the top-level and per-dataset-override checks so - both config surfaces validate identically. - """ - checks = { - "min_new_tokens": mp.min_new_tokens != 1, - "skip_special_tokens": not mp.skip_special_tokens, - } - return [name for name, non_default in checks.items() if non_default] - - -class OSLDistributionType(str, Enum): - """Output Sequence Length distribution types.""" - - ORIGINAL = "original" # Use original distribution from dataset (default) - FIXED = "fixed" # Fixed length for all outputs - UNIFORM = "uniform" # Uniform distribution between min and max - NORMAL = "normal" # Normal/Gaussian distribution - - -class StreamingMode(str, Enum): - """Streaming mode for response handling. - - - AUTO: Automatically enable for online mode, disable for offline mode - - ON: Force streaming enabled (for TTFT metrics) - - OFF: Force streaming disabled - """ - - AUTO = "auto" - ON = "on" - OFF = "off" - - -class OSLDistribution(BaseModel): - """Output Sequence Length distribution configuration. - - Distribution types: - - ORIGINAL: Use the natural distribution from the dataset (default) - - FIXED: All outputs have the same length (uses mean value) - - UNIFORM: Uniformly distributed between min and max - - NORMAL: Normal/Gaussian distribution with mean and std - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - type: OSLDistributionType = Field( - OSLDistributionType.ORIGINAL, description="Distribution type" - ) - mean: int | None = Field(None, description="Mean length (FIXED/NORMAL)") - std: int | None = Field(None, description="Std deviation (NORMAL)") - min: Annotated[ - int, - cyclopts.Parameter(alias="--min-output-tokens", help="Minimum output length"), - ] = 1 - max: int = Field(2048, description="Maximum output length") - - -class ModelParams(BaseModel): - """Model generation parameters.""" - - model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) - - name: Annotated[ - str, - cyclopts.Parameter(alias="--model", help="Model name", required=True), - ] = "" - temperature: float | None = Field(None, description="Sampling temperature") - seed: Annotated[ - int | None, - cyclopts.Parameter( - alias="--seed", help="Random seed for reproducible sampling" - ), - ] = Field(None, description="Random seed for reproducible sampling") - top_k: int | None = Field(None, description="Top-K sampling") - top_p: float | None = Field(None, description="Top-P (nucleus) sampling") - repetition_penalty: float | None = Field(None, description="Repetition penalty") - presence_penalty: float | None = Field(None, description="Presence penalty") - frequency_penalty: float | None = Field(None, description="Frequency penalty") - chat_template_kwargs: dict[str, Any] | None = Field( - None, - description="Per-request chat-template kwargs forwarded to compatible servers.", - ) - max_new_tokens: Annotated[ - int, cyclopts.Parameter(alias="--max-output-tokens", help="Max output tokens") - ] = 1024 - min_new_tokens: int = Field( - 1, - ge=0, - description="Minimum output tokens for OpenAI text-completions servers", - ) - skip_special_tokens: bool = Field( - True, - description=( - "Whether OpenAI text-completions servers omit special tokens from decoded output" - ), - ) - osl_distribution: OSLDistribution | None = Field( - None, description="Output sequence length distribution" - ) - streaming: Annotated[ - StreamingMode, - cyclopts.Parameter(alias="--streaming", help="Streaming mode: auto/on/off"), - ] = StreamingMode.AUTO - tokenizer_name: Annotated[ - str | None, - cyclopts.Parameter( - alias="--tokenizer", - help="HF repo ID or local path for the tokenizer. Overrides model name for client-side token metrics (ISL/OSL/TPOT).", - ), - ] = None - - @model_validator(mode="after") - def _validate_generation_lengths(self) -> Self: - if self.min_new_tokens > self.max_new_tokens: - raise ValueError( - "min_new_tokens must be less than or equal to max_new_tokens" - ) - return self - - -class SubmissionReference(BaseModel): - """Reference configuration for official benchmark submissions. - - Links a submission to a specific model and ruleset (competition rules). - The ruleset defines constraints like min duration, sample counts, and - performance targets that must be met for a valid submission. - - Example: - submission_ref: - model: "llama-2-70b" - ruleset: "mlperf-inference-v5.1" - """ - - model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) - - model: str # Model identifier (e.g., "llama-2-70b") - ruleset: str # Ruleset name/version (e.g., "mlperf-inference-v5.1") - - def get_ruleset_instance(self) -> BenchmarkSuiteRuleset: - """Get the actual ruleset instance from registry. - - Returns: - BenchmarkSuiteRuleset instance - - Raises: - KeyError: If ruleset not found in registry - """ - from .ruleset_registry import get_ruleset - - return get_ruleset(self.ruleset) diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 4de5a3864..30e1171db 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -15,17 +15,9 @@ """Configuration schema — single source of truth for YAML and CLI. -All Pydantic models define both the YAML config structure and the CLI -interface. cyclopts auto-generates CLI flags from fields. Use -cyclopts.Parameter(alias=...) on Annotated fields to declare shorthand -aliases alongside dotted paths. - -Split criterion: one module per config domain (audit / model_params / -datasets / settings / timeouts), with every name — model, enum, helper — -living beside its owner; this module owns only the root aggregate -(``BenchmarkConfig``, its cross-field validation, and the root-level -``TestType``/``TestMode`` enums) plus the explicit re-export hub, so every -existing ``config.schema`` import site keeps working. +All Pydantic models here define both the YAML config structure and the CLI interface. +cyclopts auto-generates CLI flags from fields. Use cyclopts.Parameter(alias=...) +on Annotated fields to declare shorthand aliases alongside dotted paths. """ from __future__ import annotations @@ -44,9 +36,11 @@ ConfigDict, Discriminator, Field, + SerializerFunctionWrapHandler, Tag, TypeAdapter, field_validator, + model_serializer, model_validator, ) @@ -54,77 +48,163 @@ from ..endpoint_client.config import HTTPClientConfig from ..exceptions import CLIError from ..utils import WithUpdatesMixin -from .audit import AuditConfig, AuditTestId, OutputCachingTestConfig -from .datasets import ( - AccuracyConfig, - AgenticInferenceConfig, - Dataset, - DatasetType, - EvalMethod, - ScorerMethod, -) -from .model_params import ( - ModelParams, - OSLDistribution, - OSLDistributionType, - StreamingMode, - SubmissionReference, - _non_default_completion_controls, -) -from .settings import ( - EarlyStoppingConfig, - LoadPattern, - LoadPatternType, - OfflineSettings, - OnlineSettings, - ProfilerEngine, - ProfilingConfig, - RuntimeConfig, - Settings, - WarmupConfig, -) -from .timeouts import Timeouts +from .ruleset_base import BenchmarkSuiteRuleset from .utils import parse_dataset_string, resolve_env_vars -__all__ = [ - "APIType", - "AccuracyConfig", - "AgenticInferenceConfig", - "AuditConfig", - "AuditTestId", - "BenchmarkConfig", - "Dataset", - "DatasetType", - "EarlyStoppingConfig", - "EndpointConfig", - "EvalMethod", - "HTTPClientConfig", - "LoadPattern", - "LoadPatternType", - "ModelParams", - "OSLDistribution", - "OSLDistributionType", - "OfflineBenchmarkConfig", - "OfflineSettings", - "OnlineBenchmarkConfig", - "OnlineSettings", - "OutputCachingTestConfig", - "ProfilerEngine", - "ProfilingConfig", - "RuntimeConfig", - "ScorerMethod", - "Settings", - "StreamingMode", - "SubmissionReference", - "TestMode", - "TestType", - "Timeouts", - "WarmupConfig", -] - logger = logging.getLogger(__name__) +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge ``override`` into ``base`` and return the result. + + For overlapping keys whose values are both dicts, recurse; otherwise the + override value wins. Mutates a *copy* — callers can safely pass model_dump() + output. Used by ``Dataset.effective_generation_config`` so a sparse nested + override (e.g. ``{osl_distribution: {max: 512}}``) preserves siblings. + """ + out = dict(base) + for k, v in override.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +# ModelParams fields that drive the single global tokenizer / MetricsAggregator +# (launched once from top-level model_params), so a per-dataset override would +# desync ISL/OSL/TTFT/TPOT accounting without changing what is measured. Rejected +# as generation_config_override keys — they are per-run/identity, not per-dataset. +_METRICS_DECOUPLED_OVERRIDE_KEYS = frozenset({"name", "streaming", "tokenizer_name"}) + + +def _non_default_completion_controls(mp: ModelParams) -> list[str]: + """Completion-only ModelParams controls set to a non-default value. + + ``min_new_tokens``/``skip_special_tokens`` are only honored by the + ``openai_completions`` adapter; ``BenchmarkConfig`` rejects them for other + ``api_type``s. Shared by the top-level and per-dataset-override checks so + both config surfaces validate identically. + """ + checks = { + "min_new_tokens": mp.min_new_tokens != 1, + "skip_special_tokens": not mp.skip_special_tokens, + } + return [name for name, non_default in checks.items() if non_default] + + +class LoadPatternType(str, Enum): + """Load pattern types.""" + + MAX_THROUGHPUT = "max_throughput" # Offline: all queries at t=0 + POISSON = "poisson" # Online: fixed QPS with Poisson distribution + CONCURRENCY = "concurrency" # Online: fixed concurrent requests + AGENTIC_INFERENCE = ( + "agentic_inference" # Agentic inference conversations with turn sequencing + ) + BURST = "burst" # Burst pattern (TODO) + STEP = "step" # Step pattern (TODO) + + +class OSLDistributionType(str, Enum): + """Output Sequence Length distribution types.""" + + ORIGINAL = "original" # Use original distribution from dataset (default) + FIXED = "fixed" # Fixed length for all outputs + UNIFORM = "uniform" # Uniform distribution between min and max + NORMAL = "normal" # Normal/Gaussian distribution + + +class DatasetType(str, Enum): + """Dataset purpose type.""" + + PERFORMANCE = "performance" + ACCURACY = "accuracy" + + +class EvalMethod(str, Enum): + """Evaluation methods for accuracy testing.""" + + EXACT_MATCH = "exact_match" + CONTAINS = "contains" + JUDGE = "judge" + + +class ScorerMethod(str, Enum): + """Registered scorer methods for accuracy evaluation.""" + + PASS_AT_1 = "pass_at_1" + STRING_MATCH = "string_match" + ROUGE = "rouge" + CODE_BENCH = "code_bench_scorer" + SHOPIFY_CATEGORY_F1 = "shopify_category_f1" + AGENTIC_INFERENCE_INLINE = "agentic_inference_inline" + VBENCH = "vbench" + BFCL_V4 = "bfcl_v4" + LEGACY_MLPERF_DEEPSEEK_R1 = "legacy_mlperf_deepseek_r1" + SWE_BENCH = "swe_bench_scorer" + + +# --------------------------------------------------------------------- audit +# The root-level ``audit:`` block: per-test config models and their id enum. +# The runnable test registry lives in ``compliance/``; these are small enough +# to live beside the root aggregate they plug into. +class AuditTestId(str, Enum): + """Registered compliance audit test identifiers.""" + + # Output-caching audit — MLPerf TEST04 (duplicate-query caching detection). + OUTPUT_CACHING_TEST = "output_caching_test" + + +class OutputCachingTestConfig(BaseModel): + """Configuration for the output-caching audit (MLPerf TEST04). + + The output-caching test runs two back-to-back phases — a reference run of + distinct samples and an audit run that repeats one fixed sample — then + checks that the audit QPS does not exceed the reference QPS by more than + ``threshold``. A large speedup indicates the SUT is caching responses. + + samples: reference-phase query count (required — an explicit count keeps + the per-phase completion check meaningful; a duration-driven phase has + no independent target to validate completion against) + audit_samples: audit-phase query count (None → equals samples) + sample_index: which dataset row is repeated (MLCommons performance_issue_same_index) + threshold: tolerance shared by both pass checks — each phase must complete + ≥ requested * (1 - threshold), and audit_qps must stay < ref_qps * (1 + threshold) + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + test: Literal[AuditTestId.OUTPUT_CACHING_TEST] + only: bool = Field( + False, + description="Run only the audit — skip the main benchmark (upstream-style standalone TEST04)", + ) + samples: int = Field(..., ge=1, description="Reference phase query count") + audit_samples: int | None = Field( + None, ge=1, description="Audit phase query count (default: equals samples)" + ) + sample_index: int = Field( + 0, ge=0, description="Dataset row index repeated in the audit phase" + ) + threshold: float = Field( + 0.10, + gt=0, + lt=1, + description=( + "Tolerance for both checks: each phase must complete " + "≥ requested * (1 - threshold), and audit_qps must stay " + "< ref_qps * (1 + threshold)" + ), + ) + + +# Single member today; becomes +# Annotated[OutputCachingTestConfig | ..., Field(discriminator="test")] +# when additional audit tests are added. +AuditConfig = OutputCachingTestConfig + + class TestMode(str, Enum): """Test mode controlling performance issuance and response collection. @@ -139,6 +219,19 @@ class TestMode(str, Enum): BOTH = "both" +class StreamingMode(str, Enum): + """Streaming mode for response handling. + + - AUTO: Automatically enable for online mode, disable for offline mode + - ON: Force streaming enabled (for TTFT metrics) + - OFF: Force streaming disabled + """ + + AUTO = "auto" + ON = "on" + OFF = "off" + + class TestType(str, Enum): """Test type for both config classification and execution mode. @@ -154,6 +247,766 @@ class TestType(str, Enum): SUBMISSION = "submission" +class OSLDistribution(BaseModel): + """Output Sequence Length distribution configuration. + + Distribution types: + - ORIGINAL: Use the natural distribution from the dataset (default) + - FIXED: All outputs have the same length (uses mean value) + - UNIFORM: Uniformly distributed between min and max + - NORMAL: Normal/Gaussian distribution with mean and std + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: OSLDistributionType = Field( + OSLDistributionType.ORIGINAL, description="Distribution type" + ) + mean: int | None = Field(None, description="Mean length (FIXED/NORMAL)") + std: int | None = Field(None, description="Std deviation (NORMAL)") + min: Annotated[ + int, + cyclopts.Parameter(alias="--min-output-tokens", help="Minimum output length"), + ] = 1 + max: int = Field(2048, description="Maximum output length") + + +class ModelParams(BaseModel): + """Model generation parameters.""" + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + name: Annotated[ + str, + cyclopts.Parameter(alias="--model", help="Model name", required=True), + ] = "" + temperature: float | None = Field(None, description="Sampling temperature") + seed: Annotated[ + int | None, + cyclopts.Parameter( + alias="--seed", help="Random seed for reproducible sampling" + ), + ] = Field(None, description="Random seed for reproducible sampling") + top_k: int | None = Field(None, description="Top-K sampling") + top_p: float | None = Field(None, description="Top-P (nucleus) sampling") + repetition_penalty: float | None = Field(None, description="Repetition penalty") + presence_penalty: float | None = Field(None, description="Presence penalty") + frequency_penalty: float | None = Field(None, description="Frequency penalty") + chat_template_kwargs: dict[str, Any] | None = Field( + None, + description="Per-request chat-template kwargs forwarded to compatible servers.", + ) + max_new_tokens: Annotated[ + int, cyclopts.Parameter(alias="--max-output-tokens", help="Max output tokens") + ] = 1024 + min_new_tokens: int = Field( + 1, + ge=0, + description="Minimum output tokens for OpenAI text-completions servers", + ) + skip_special_tokens: bool = Field( + True, + description=( + "Whether OpenAI text-completions servers omit special tokens from decoded output" + ), + ) + osl_distribution: OSLDistribution | None = Field( + None, description="Output sequence length distribution" + ) + streaming: Annotated[ + StreamingMode, + cyclopts.Parameter(alias="--streaming", help="Streaming mode: auto/on/off"), + ] = StreamingMode.AUTO + tokenizer_name: Annotated[ + str | None, + cyclopts.Parameter( + alias="--tokenizer", + help="HF repo ID or local path for the tokenizer. Overrides model name for client-side token metrics (ISL/OSL/TPOT).", + ), + ] = None + + @model_validator(mode="after") + def _validate_generation_lengths(self) -> Self: + if self.min_new_tokens > self.max_new_tokens: + raise ValueError( + "min_new_tokens must be less than or equal to max_new_tokens" + ) + return self + + +class SubmissionReference(BaseModel): + """Reference configuration for official benchmark submissions. + + Links a submission to a specific model and ruleset (competition rules). + The ruleset defines constraints like min duration, sample counts, and + performance targets that must be met for a valid submission. + + Example: + submission_ref: + model: "llama-2-70b" + ruleset: "mlperf-inference-v5.1" + """ + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + model: str # Model identifier (e.g., "llama-2-70b") + ruleset: str # Ruleset name/version (e.g., "mlperf-inference-v5.1") + + def get_ruleset_instance(self) -> BenchmarkSuiteRuleset: + """Get the actual ruleset instance from registry. + + Returns: + BenchmarkSuiteRuleset instance + + Raises: + KeyError: If ruleset not found in registry + """ + from .ruleset_registry import get_ruleset + + return get_ruleset(self.ruleset) + + +class AgenticInferenceConfig(BaseModel): + """Agentic inference conversation configuration. + + Configuration for benchmarking conversational AI workloads with turn sequencing. + Enables testing agentic inference conversations where each turn depends on previous responses. + Presence of this block in the dataset config enables agentic inference mode. + + Attributes: + turn_timeout_s: Deadline between issuing a turn and receiving its + response. A timeout aborts that turn and all remaining client + turns of the same conversation because subsequent turns depend + on the timed-out response. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + turn_timeout_s: float = Field( + default=86400.0, + gt=0, + description=( + "Per-turn timeout in seconds. A timeout aborts that turn and all " + "remaining turns in the same conversation." + ), + ) + enable_salt: bool = Field( + False, + description=( + "Add deterministic salt markers before and after the system prompt " + "to prevent KV cache reuse across trajectories in agentic inference setting." + ), + ) + inject_tool_delay: bool = Field( + False, + description=( + "Pause for a predefined duration between turns. Duration is defined " + "in dataset." + ), + ) + routing_headers: tuple[str, ...] = Field( + default=("X-Session-ID",), + description=( + "HTTP header names populated with the conversation ID on every " + "agentic request." + ), + ) + num_trajectories_to_issue: int | None = Field( + default=None, + gt=0, + description=( + "Number of conversation trajectories to start. Defaults to one pass " + "over the dataset; values above the dataset size repeat trajectories " + "with unique logical conversation ids." + ), + ) + stop_issuing_on_first_user_complete: bool = Field( + False, + description=( + "When performance tracking stops because the first concurrency slot " + "has no next trajectory left to assign, also stop issuing future " + "turns. If false, replay continues outside the performance window " + "for accuracy/log coverage." + ), + ) + + +class Dataset(BaseModel): + """Dataset configuration. + + Name and type have smart defaults: name is auto-derived from path, + type defaults to PERFORMANCE. + + Accepts CLI strings via BeforeValidator on BenchmarkConfig.datasets: + ``[perf|acc:][,key=value...]`` + """ + + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + name: str = Field("", description="Dataset name (auto-derived from path if empty)") + type: DatasetType = Field( + DatasetType.PERFORMANCE, description="Dataset purpose: performance or accuracy" + ) + path: Annotated[ + str | None, cyclopts.Parameter(alias="--dataset", help="Dataset file path") + ] = None + format: str | None = Field(None, description="Dataset format (auto-detected)") + samples: int | None = Field(None, gt=0, description="Number of samples to use") + eval_method: EvalMethod | None = Field( + None, description="Accuracy evaluation method" + ) + parser: dict[str, str] | None = Field( + None, description="Column remapping: {prompt: , system: }" + ) + generate_params: dict[str, Any] | None = Field( + None, description="Dataset-specific parameters passed to the generate() method" + ) + accuracy_config: AccuracyConfig | None = Field( + None, description="Accuracy evaluation settings" + ) + agentic_inference: AgenticInferenceConfig | None = Field( + None, description="Agentic inference conversation configuration" + ) + # Per-dataset generation config is a first-class capability: different + # accuracy datasets legitimately want different generation settings (e.g. + # per-dataset max OSL or top_p, as seen in DS-V4), and dataset-scoping also + # enables per-dataset dynamic OSL distributions. Only generation knobs are + # overridable — per-run/identity fields (`_METRICS_DECOUPLED_OVERRIDE_KEYS`: + # name / streaming / tokenizer_name) drive the single global tokenizer and + # MetricsAggregator, so overriding them per-dataset would desync ISL/OSL/ + # TTFT/TPOT accounting; they are rejected at validation. + # + # TODO(post-mortem): split ModelParams into a per-run ModelIdentity and a + # GenerationConfig, so the override surface is exactly the generation fields + # and identity fields cannot be named here at all. Field/method names use + # "generation_config" to keep that migration mechanical. + # + # Nested dicts (`osl_distribution`, `chat_template_kwargs`) are deep-merged + # so sparse overrides preserve sibling defaults. + generation_config_override: dict[str, Any] | None = Field( + None, + description=( + "Per-dataset overrides for the top-level model_params (sparse — " + "only the fields you want to override). Merged on top of " + "BenchmarkConfig.model_params at dataset-load time. Useful for " + "MLPerf-style runs where accuracy and performance use different " + "output budgets in the same fleet, e.g. " + "generation_config_override: {max_new_tokens: 32768, " + "temperature: 0.0}. NOTE: per-run/identity keys (`name`, " + "`streaming`, `tokenizer_name`) are rejected here — set them on " + "top-level model_params." + ), + ) + + @model_validator(mode="after") + def _auto_derive_name(self) -> Self: + """Derive name from path stem if not explicitly provided.""" + if not self.name and self.path: + object.__setattr__(self, "name", Path(self.path).stem) + return self + + @model_validator(mode="after") + def _validate_generation_config_override(self) -> Self: + """Fail fast on unknown keys and on per-run/identity keys the single + global tokenizer / MetricsAggregator would ignore. Override *values* + are validated at merge time (see ``effective_generation_config``) + because cross-field validation needs the base ``ModelParams`` from + ``BenchmarkConfig``. + """ + if self.generation_config_override: + keys = set(self.generation_config_override) + valid = set(ModelParams.model_fields) + bad = sorted(keys - valid) + if bad: + raise ValueError( + f"Dataset '{self.name}': unknown keys in " + f"generation_config_override: {bad}. " + f"Valid keys: {sorted(valid)}" + ) + decoupled = sorted(keys & _METRICS_DECOUPLED_OVERRIDE_KEYS) + if decoupled: + raise ValueError( + f"Dataset '{self.name}': generation_config_override keys " + f"{decoupled} are not honored per-dataset — the single " + "global tokenizer / metrics aggregator is launched from " + "top-level model_params, so a per-dataset value would " + "desync ISL/OSL/TTFT/TPOT accounting. Set them on " + "top-level model_params instead." + ) + return self + + def effective_generation_config(self, base: ModelParams) -> ModelParams: + """Return base merged with this dataset's generation-config overrides. + + Nested dicts are deep-merged so a sparse nested override preserves + sibling defaults (e.g. ``{osl_distribution: {max: 512}}`` keeps the + base ``type/mean/std/min``). The merged dict is re-validated through + ``ModelParams.model_validate`` so type-invalid scalar overrides (e.g. + ``temperature: 'hot'``) are rejected. Note that this only catches + scalar invalidity — a sparse nested override whose merged result + passes default-validation will not raise (callers that need stricter + nested validation should set ``base`` to an explicit instance). + """ + if not self.generation_config_override: + return base + merged = _deep_merge(base.model_dump(), self.generation_config_override) + return ModelParams.model_validate(merged) + + +class AccuracyConfig(BaseModel): + """Accuracy configuration. + + eval_method: Scorer to use (see ScorerMethod enum for options). + ground_truth: Column in the dataset containing ground truth. Defaults to "ground_truth". + extractor: Post-processor to extract answers from model output + (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor). + Optional for scorers that declare REQUIRES_EXTRACTOR = False (e.g. vbench). + num_repeats: Number of times to repeat the dataset for evaluation. Defaults to 1. + extras: Free-form keyword args forwarded to the scorer's ``__init__`` — + used for scorer-specific knobs that don't warrant a top-level field + (e.g. ``vbench_project_path``, ``subprocess_timeout_s`` for VBench). + + Example: + accuracy_config: + eval_method: "pass_at_1" + ground_truth: "answer" + extractor: "boxed_math_extractor" + num_repeats: 5 + extras: + vbench_project_path: "/path/to/accuracy" + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + eval_method: ScorerMethod | None = Field(None, description="Scorer method") + ground_truth: str | None = Field(None, description="Ground truth column name") + extractor: str | None = Field( + None, + description="Answer extractor (abcd_extractor, boxed_math_extractor, identity_extractor, python_code_extractor)", + ) + num_repeats: int = Field( + 1, ge=1, description="Repeat dataset N times for evaluation" + ) + extras: dict[str, Any] | None = Field( + None, + description="Free-form scorer kwargs (e.g. vbench_project_path, subprocess_timeout_s)", + ) + + +class RuntimeConfig(BaseModel): + """Runtime configuration. + + Sample count priority (in RuntimeSettings.total_samples_to_issue()): + 1. n_samples_to_issue (if specified) — explicit override + 2. All dataset samples — issue the dataset once + + ``max_duration_ms`` is a workload duration (part of the benchmark + definition), not a give-up deadline — those live in ``settings.timeouts``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + max_duration_ms: int | None = Field( + None, + gt=0, + description="Maximum test duration in ms (None for no limit)", + ) + + @field_validator("max_duration_ms", mode="before") + @classmethod + def _parse_duration_suffix(cls, v: object) -> object: + """Accept duration with unit suffix: 600s, 10m, 600000ms, or plain int (ms).""" + if isinstance(v, str): + v = v.strip() + if v.endswith("ms"): + return int(v[:-2]) + if v.endswith("m"): + return int(float(v[:-1]) * 60_000) + if v.endswith("s"): + return int(float(v[:-1]) * 1000) + return v + + n_samples_to_issue: Annotated[ + int | None, + cyclopts.Parameter(alias="--num-samples", help="Sample count override"), + ] = Field(None, gt=0) + scheduler_random_seed: int = Field(42, description="Scheduler RNG seed") + dataloader_random_seed: int = Field(42, description="Dataloader RNG seed") + + +@cyclopts.Parameter(name="*") +class LoadPattern(BaseModel): + """Load pattern configuration. + + Different patterns use target_qps differently: + - max_throughput: target_qps used for calculating total queries (offline, optional with default) + - poisson: target_qps sets scheduler rate (online, required - validated) + - concurrency: issue at fixed target_concurrency (online, required - validated) + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + type: Annotated[ + LoadPatternType, + cyclopts.Parameter(name="--load-pattern", help="Load pattern type"), + ] = LoadPatternType.MAX_THROUGHPUT + target_qps: Annotated[ + float | None, cyclopts.Parameter(alias="--target-qps", help="Target QPS") + ] = Field(None, gt=0) + target_concurrency: Annotated[ + int | None, + cyclopts.Parameter(alias="--concurrency", help="Concurrent requests"), + ] = Field(None, gt=0) + + # TODO(vir): remove once the formal tail-cutting mechanism lands. + use_legacy_loadgen_qps_metrics: Annotated[ + bool, + cyclopts.Parameter( + negative="--no-use-legacy-loadgen-qps-metrics", + help=( + "Only applies to the poisson load pattern. Report QPS/TPS using " + "the legacy MLPerf LoadGen Server 'completed' definition — (completed-1)/T " + "and tokens/T, T = first issued request to completion of the " + "last-issued request (see mlcommons/inference loadgen/results.cc). " + "--no-... uses endpoints-native completed/duration. Ignored for " + "non-poisson patterns." + ), + ), + ] = True + + @model_serializer(mode="wrap") + def _serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + # use_legacy_loadgen_qps_metrics only applies to poisson; drop it from + # the serialized form (and thus YAML templates) for other patterns. + data = handler(self) + if self.type != LoadPatternType.POISSON: + data.pop("use_legacy_loadgen_qps_metrics", None) + return data + + @model_validator(mode="after") + def _validate_completeness(self) -> Self: + if self.type == LoadPatternType.POISSON and ( + self.target_qps is None or self.target_qps <= 0 + ): + raise ValueError("Poisson requires --target-qps (e.g., --target-qps 100)") + if self.type == LoadPatternType.CONCURRENCY and ( + not self.target_concurrency or self.target_concurrency <= 0 + ): + raise ValueError( + "Concurrency requires --concurrency (e.g., --concurrency 10)" + ) + if self.type == LoadPatternType.AGENTIC_INFERENCE and ( + not self.target_concurrency or self.target_concurrency <= 0 + ): + raise ValueError( + "Agentic inference requires --concurrency (e.g., --concurrency 96)" + ) + return self + + def __str__(self) -> str: + """Human-readable "type (param=value)" form for logging, e.g. + ``concurrency (target_concurrency=7)`` / ``poisson (target_qps=10.0)``. + Patterns without a driving parameter render as just the type name. + """ + if self.type in ( + LoadPatternType.CONCURRENCY, + LoadPatternType.AGENTIC_INFERENCE, + ): + return f"{self.type.value} (target_concurrency={self.target_concurrency})" + if self.type == LoadPatternType.POISSON: + return f"{self.type.value} (target_qps={self.target_qps})" + return self.type.value + + +@cyclopts.Parameter(name="*") +class WarmupConfig(BaseModel): + """Warmup phase configuration. Runs before the performance phase; results are not recorded.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + enabled: Annotated[ + bool, + cyclopts.Parameter( + alias="--warmup", help="Enable warmup phase before performance run" + ), + ] = Field(False, description="Enable warmup phase before performance run") + n_requests: Annotated[ + int | None, + cyclopts.Parameter( + alias="--warmup-requests", + help="Warmup request count (None = full dataset once)", + ), + ] = Field(None, gt=0, description="Warmup request count (None = full dataset once)") + salt: Annotated[ + bool, + cyclopts.Parameter( + alias="--warmup-salt", + help="Prepend a unique random hex salt to each warmup prompt", + ), + ] = Field( + True, description="Prepend a unique random hex salt to each warmup prompt" + ) + drain: Annotated[ + bool, + cyclopts.Parameter( + alias="--warmup-drain", + help="Drain in-flight warmup requests before starting the performance phase", + ), + ] = Field( + False, + description="Drain in-flight warmup requests before starting the performance phase", + ) + warmup_random_seed: Annotated[ + int, + cyclopts.Parameter( + alias="--warmup-seed", + help="RNG seed for warmup scheduling and sample ordering", + ), + ] = Field(42, description="RNG seed for warmup scheduling and sample ordering") + + +class Timeouts(WithUpdatesMixin, BaseModel): + """All global waits and deadlines. ``None`` = wait indefinitely / off. + + Reaching an optional deadline means something is stuck; ``run_timeout_s`` + is the whole-run watchdog — when it fires the run is aborted and the + report is marked INTERRUPTED. It never derives or caps the other + deadlines. Workload durations (``runtime.max_duration_ms``) are NOT + timeouts and do not live here. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + run_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--timeout", + help=( + "Whole-run watchdog in seconds (None = off). Firing aborts the " + "run and marks the report INTERRUPTED." + ), + ), + ] = Field( + None, + gt=0, + description=( + "Whole-run watchdog in seconds (None = off). Covers every phase " + "including drains; firing aborts the run, marks the report " + "INTERRUPTED, and exits non-zero. Never derives per-stage deadlines." + ), + ) + service_ready_timeout_s: Annotated[ + float, + cyclopts.Parameter( + alias="--service-ready-timeout", + help="Seconds to wait for metrics/event-logger services to start", + ), + ] = Field( + 30.0, + ge=0, + description="Seconds to wait for metrics-aggregator/event-logger services to become ready.", + ) + warmup_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--warmup-drain-timeout", + help="Warmup drain timeout in seconds (None = wait indefinitely)", + ), + ] = Field( + 240.0, + gt=0, + description="Warmup drain timeout in seconds (None = wait indefinitely)", + ) + performance_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--performance-drain-timeout", + help="Performance drain timeout in seconds (None = wait indefinitely)", + ), + ] = Field( + None, + gt=0, + description="Performance drain timeout in seconds (None = wait indefinitely)", + ) + accuracy_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--accuracy-drain-timeout", + help="Accuracy drain timeout in seconds (None = wait indefinitely)", + ), + ] = Field( + None, + gt=0, + description=( + "Accuracy drain timeout in seconds (None = wait indefinitely; " + "accuracy is unbounded by default because every sample must complete)" + ), + ) + metrics_drain_timeout_s: Annotated[ + float | None, + cyclopts.Parameter( + alias="--metrics-drain-timeout", + help=( + "Wall-clock budget (seconds) for the metrics aggregator to finish " + "tokenizing buffered samples after the run ends " + "(None = wait indefinitely)" + ), + ), + ] = Field( + None, + gt=0, + description=( + "Wall-clock budget (seconds) to finish tokenizing buffered samples " + "after ENDED (None = wait indefinitely). An incomplete drain fails " + "the run: artifacts are written with complete: false, then " + "run_benchmark exits non-zero." + ), + ) + + +class ProfilerEngine(str, Enum): + """Inference engine whose profiling protocol the client should drive. + + Selects the HTTP path layout used to derive start/stop URLs from + ``endpoint_config.endpoints``. Each value corresponds to one server-side + profiling protocol; add a new variant + ``_PROFILE_PATHS`` row to support + another engine. + """ + + VLLM = "vllm" + + +@cyclopts.Parameter(name="*") +class ProfilingConfig(BaseModel): + """Client-side trigger for the server's profiler. + + When ``engine`` is set, fires POST ```` at performance-phase + begin and POST ```` at performance-phase end. URLs are derived + using the engine-specific protocol from ``urls`` when set, otherwise + from ``endpoint_config.endpoints``. + Server must be launched with profiling enabled (e.g. vLLM's + ``--profiler-config.profiler=cuda|torch``); the schedule + (``delay_iterations``, ``max_iterations``) is set there, not here. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + engine: Annotated[ + ProfilerEngine | None, + cyclopts.Parameter( + alias="--profile", + help="Profile the named inference engine around the performance phase", + ), + ] = Field( + None, + description="Profile the named inference engine around the performance phase", + ) + urls: Annotated[ + list[str] | None, + cyclopts.Parameter( + alias="--profile-urls", + help="Override URL(s) for profiler triggers; " + "defaults to endpoint_config.endpoints", + negative="", + ), + ] = Field( + None, + description="URL(s) the profiler start/stop triggers are derived from. " + "When None, derived from endpoint_config.endpoints instead. Use when " + "the profiler admin endpoint differs from the inference endpoint.", + ) + + @field_validator("urls", mode="after") + @classmethod + def _validate_url_scheme(cls, v: list[str] | None) -> list[str] | None: + if v is None: + return v + for url in v: + if not url.startswith(("http://", "https://")): + raise ValueError( + f"Profiling endpoint URL must include scheme " + f"(http:// or https://), got: {url!r}" + ) + return v + + +class EarlyStoppingConfig(BaseModel): + """MLPerf-style early-stopping percentile estimates (on by default). + + Adds conservative, confidence-backed estimates of the tail percentiles to the + TTFT / TPOT / latency metrics in ``result_summary.json``. Computed once at run + COMPLETE from data the aggregator already keeps (hot path untouched), and the + output field is additive — so it is on by default; ``enabled: false`` is the + single opt-out (e.g. for consumers that strictly validate the summary schema). + Percentile targets, confidence (0.99), and tolerance (0.0) are LoadGen-parity + constants in ``metrics/early_stopping.py``, not knobs. Estimate-only: no + target-latency pass/fail and no dynamic mid-run halt. See ``docs/early_stopping.md``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + enabled: Annotated[ + bool, + cyclopts.Parameter( + alias="--early-stopping", # --no-early-stopping is the meaningful opt-out + help="Report MLPerf early-stopping percentile estimates for TTFT/TPOT/latency", + ), + ] = Field(True, description="Early-stopping percentile estimates (default on)") + + +@cyclopts.Parameter(name="*") +class Settings(BaseModel): + """Test settings.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) + load_pattern: LoadPattern = Field(default_factory=LoadPattern) + client: HTTPClientConfig = Field(default_factory=HTTPClientConfig) + timeouts: Timeouts = Field( + default_factory=Timeouts, + description="All global waits and deadlines (see config/schema.py)", + ) + warmup: WarmupConfig = Field(default_factory=WarmupConfig) + profiling: ProfilingConfig = Field(default_factory=ProfilingConfig) + early_stopping: EarlyStoppingConfig = Field( + default_factory=EarlyStoppingConfig, + description="MLPerf early-stopping percentile estimates (on by default; enabled: false opts out)", + ) + metrics_tokenizer_workers: Annotated[ + int, + cyclopts.Parameter( + alias="--metrics-tokenizer-workers", + help=( + "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT in " + "the metrics aggregator. 0 defers all tokenization to the " + "end-of-run drain, which always uses the auto-sized sharded pool." + ), + ), + ] = Field( + 4, + ge=0, + description=( + "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT " + "(default: 4; 0 = defer everything to the end-of-run drain)." + ), + ) + + +class OfflineSettings(Settings): + """Offline mode default settings.""" + + load_pattern: Annotated[LoadPattern, cyclopts.Parameter(show=False)] = Field( + default_factory=lambda: LoadPattern(type=LoadPatternType.MAX_THROUGHPUT) + ) + + +class OnlineSettings(Settings): + """Online mode default settings.""" + + pass + + class EndpointConfig(BaseModel): """Endpoint connection configuration. diff --git a/src/inference_endpoint/config/settings.py b/src/inference_endpoint/config/settings.py deleted file mode 100644 index 78062be7d..000000000 --- a/src/inference_endpoint/config/settings.py +++ /dev/null @@ -1,368 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Test settings models (the ``settings:`` block). - -Split criterion: one module per config domain; the runtime/load-pattern/ -warmup/profiling settings and the ``Settings`` aggregate live here. -``config/schema.py`` re-exports the public surface. -""" - -from __future__ import annotations - -from enum import Enum -from typing import Annotated, Any, Self - -import cyclopts -from pydantic import ( - BaseModel, - ConfigDict, - Field, - SerializerFunctionWrapHandler, - field_validator, - model_serializer, - model_validator, -) - -from ..endpoint_client.config import HTTPClientConfig -from .timeouts import Timeouts - - -class LoadPatternType(str, Enum): - """Load pattern types.""" - - MAX_THROUGHPUT = "max_throughput" # Offline: all queries at t=0 - POISSON = "poisson" # Online: fixed QPS with Poisson distribution - CONCURRENCY = "concurrency" # Online: fixed concurrent requests - AGENTIC_INFERENCE = ( - "agentic_inference" # Agentic inference conversations with turn sequencing - ) - BURST = "burst" # Burst pattern (TODO) - STEP = "step" # Step pattern (TODO) - - -class RuntimeConfig(BaseModel): - """Runtime configuration. - - Sample count priority (in RuntimeSettings.total_samples_to_issue()): - 1. n_samples_to_issue (if specified) — explicit override - 2. All dataset samples — issue the dataset once - - ``max_duration_ms`` is a workload duration (part of the benchmark - definition), not a give-up deadline — those live in ``settings.timeouts``. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - max_duration_ms: int | None = Field( - None, - gt=0, - description="Maximum test duration in ms (None for no limit)", - ) - - @field_validator("max_duration_ms", mode="before") - @classmethod - def _parse_duration_suffix(cls, v: object) -> object: - """Accept duration with unit suffix: 600s, 10m, 600000ms, or plain int (ms).""" - if isinstance(v, str): - v = v.strip() - if v.endswith("ms"): - return int(v[:-2]) - if v.endswith("m"): - return int(float(v[:-1]) * 60_000) - if v.endswith("s"): - return int(float(v[:-1]) * 1000) - return v - - n_samples_to_issue: Annotated[ - int | None, - cyclopts.Parameter(alias="--num-samples", help="Sample count override"), - ] = Field(None, gt=0) - scheduler_random_seed: int = Field(42, description="Scheduler RNG seed") - dataloader_random_seed: int = Field(42, description="Dataloader RNG seed") - - -@cyclopts.Parameter(name="*") -class LoadPattern(BaseModel): - """Load pattern configuration. - - Different patterns use target_qps differently: - - max_throughput: target_qps used for calculating total queries (offline, optional with default) - - poisson: target_qps sets scheduler rate (online, required - validated) - - concurrency: issue at fixed target_concurrency (online, required - validated) - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - type: Annotated[ - LoadPatternType, - cyclopts.Parameter(name="--load-pattern", help="Load pattern type"), - ] = LoadPatternType.MAX_THROUGHPUT - target_qps: Annotated[ - float | None, cyclopts.Parameter(alias="--target-qps", help="Target QPS") - ] = Field(None, gt=0) - target_concurrency: Annotated[ - int | None, - cyclopts.Parameter(alias="--concurrency", help="Concurrent requests"), - ] = Field(None, gt=0) - - # TODO(vir): remove once the formal tail-cutting mechanism lands. - use_legacy_loadgen_qps_metrics: Annotated[ - bool, - cyclopts.Parameter( - negative="--no-use-legacy-loadgen-qps-metrics", - help=( - "Only applies to the poisson load pattern. Report QPS/TPS using " - "the legacy MLPerf LoadGen Server 'completed' definition — (completed-1)/T " - "and tokens/T, T = first issued request to completion of the " - "last-issued request (see mlcommons/inference loadgen/results.cc). " - "--no-... uses endpoints-native completed/duration. Ignored for " - "non-poisson patterns." - ), - ), - ] = True - - @model_serializer(mode="wrap") - def _serialize(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: - # use_legacy_loadgen_qps_metrics only applies to poisson; drop it from - # the serialized form (and thus YAML templates) for other patterns. - data = handler(self) - if self.type != LoadPatternType.POISSON: - data.pop("use_legacy_loadgen_qps_metrics", None) - return data - - @model_validator(mode="after") - def _validate_completeness(self) -> Self: - if self.type == LoadPatternType.POISSON and ( - self.target_qps is None or self.target_qps <= 0 - ): - raise ValueError("Poisson requires --target-qps (e.g., --target-qps 100)") - if self.type == LoadPatternType.CONCURRENCY and ( - not self.target_concurrency or self.target_concurrency <= 0 - ): - raise ValueError( - "Concurrency requires --concurrency (e.g., --concurrency 10)" - ) - if self.type == LoadPatternType.AGENTIC_INFERENCE and ( - not self.target_concurrency or self.target_concurrency <= 0 - ): - raise ValueError( - "Agentic inference requires --concurrency (e.g., --concurrency 96)" - ) - return self - - def __str__(self) -> str: - """Human-readable "type (param=value)" form for logging, e.g. - ``concurrency (target_concurrency=7)`` / ``poisson (target_qps=10.0)``. - Patterns without a driving parameter render as just the type name. - """ - if self.type in ( - LoadPatternType.CONCURRENCY, - LoadPatternType.AGENTIC_INFERENCE, - ): - return f"{self.type.value} (target_concurrency={self.target_concurrency})" - if self.type == LoadPatternType.POISSON: - return f"{self.type.value} (target_qps={self.target_qps})" - return self.type.value - - -@cyclopts.Parameter(name="*") -class WarmupConfig(BaseModel): - """Warmup phase configuration. Runs before the performance phase; results are not recorded.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - enabled: Annotated[ - bool, - cyclopts.Parameter( - alias="--warmup", help="Enable warmup phase before performance run" - ), - ] = Field(False, description="Enable warmup phase before performance run") - n_requests: Annotated[ - int | None, - cyclopts.Parameter( - alias="--warmup-requests", - help="Warmup request count (None = full dataset once)", - ), - ] = Field(None, gt=0, description="Warmup request count (None = full dataset once)") - salt: Annotated[ - bool, - cyclopts.Parameter( - alias="--warmup-salt", - help="Prepend a unique random hex salt to each warmup prompt", - ), - ] = Field( - True, description="Prepend a unique random hex salt to each warmup prompt" - ) - drain: Annotated[ - bool, - cyclopts.Parameter( - alias="--warmup-drain", - help="Drain in-flight warmup requests before starting the performance phase", - ), - ] = Field( - False, - description="Drain in-flight warmup requests before starting the performance phase", - ) - warmup_random_seed: Annotated[ - int, - cyclopts.Parameter( - alias="--warmup-seed", - help="RNG seed for warmup scheduling and sample ordering", - ), - ] = Field(42, description="RNG seed for warmup scheduling and sample ordering") - - -class ProfilerEngine(str, Enum): - """Inference engine whose profiling protocol the client should drive. - - Selects the HTTP path layout used to derive start/stop URLs from - ``endpoint_config.endpoints``. Each value corresponds to one server-side - profiling protocol; add a new variant + ``_PROFILE_PATHS`` row to support - another engine. - """ - - VLLM = "vllm" - - -@cyclopts.Parameter(name="*") -class ProfilingConfig(BaseModel): - """Client-side trigger for the server's profiler. - - When ``engine`` is set, fires POST ```` at performance-phase - begin and POST ```` at performance-phase end. URLs are derived - using the engine-specific protocol from ``urls`` when set, otherwise - from ``endpoint_config.endpoints``. - Server must be launched with profiling enabled (e.g. vLLM's - ``--profiler-config.profiler=cuda|torch``); the schedule - (``delay_iterations``, ``max_iterations``) is set there, not here. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - engine: Annotated[ - ProfilerEngine | None, - cyclopts.Parameter( - alias="--profile", - help="Profile the named inference engine around the performance phase", - ), - ] = Field( - None, - description="Profile the named inference engine around the performance phase", - ) - urls: Annotated[ - list[str] | None, - cyclopts.Parameter( - alias="--profile-urls", - help="Override URL(s) for profiler triggers; " - "defaults to endpoint_config.endpoints", - negative="", - ), - ] = Field( - None, - description="URL(s) the profiler start/stop triggers are derived from. " - "When None, derived from endpoint_config.endpoints instead. Use when " - "the profiler admin endpoint differs from the inference endpoint.", - ) - - @field_validator("urls", mode="after") - @classmethod - def _validate_url_scheme(cls, v: list[str] | None) -> list[str] | None: - if v is None: - return v - for url in v: - if not url.startswith(("http://", "https://")): - raise ValueError( - f"Profiling endpoint URL must include scheme " - f"(http:// or https://), got: {url!r}" - ) - return v - - -class EarlyStoppingConfig(BaseModel): - """MLPerf-style early-stopping percentile estimates (on by default). - - Adds conservative, confidence-backed estimates of the tail percentiles to the - TTFT / TPOT / latency metrics in ``result_summary.json``. Computed once at run - COMPLETE from data the aggregator already keeps (hot path untouched), and the - output field is additive — so it is on by default; ``enabled: false`` is the - single opt-out (e.g. for consumers that strictly validate the summary schema). - Percentile targets, confidence (0.99), and tolerance (0.0) are LoadGen-parity - constants in ``metrics/early_stopping.py``, not knobs. Estimate-only: no - target-latency pass/fail and no dynamic mid-run halt. See ``docs/early_stopping.md``. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - enabled: Annotated[ - bool, - cyclopts.Parameter( - alias="--early-stopping", # --no-early-stopping is the meaningful opt-out - help="Report MLPerf early-stopping percentile estimates for TTFT/TPOT/latency", - ), - ] = Field(True, description="Early-stopping percentile estimates (default on)") - - -@cyclopts.Parameter(name="*") -class Settings(BaseModel): - """Test settings.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) - load_pattern: LoadPattern = Field(default_factory=LoadPattern) - client: HTTPClientConfig = Field(default_factory=HTTPClientConfig) - timeouts: Timeouts = Field( - default_factory=Timeouts, - description="All global waits and deadlines (see config/timeouts.py)", - ) - warmup: WarmupConfig = Field(default_factory=WarmupConfig) - profiling: ProfilingConfig = Field(default_factory=ProfilingConfig) - early_stopping: EarlyStoppingConfig = Field( - default_factory=EarlyStoppingConfig, - description="MLPerf early-stopping percentile estimates (on by default; enabled: false opts out)", - ) - metrics_tokenizer_workers: Annotated[ - int, - cyclopts.Parameter( - alias="--metrics-tokenizer-workers", - help=( - "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT in " - "the metrics aggregator. 0 defers all tokenization to the " - "end-of-run drain, which always uses the auto-sized sharded pool." - ), - ), - ] = Field( - 4, - ge=0, - description=( - "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT " - "(default: 4; 0 = defer everything to the end-of-run drain)." - ), - ) - - -class OfflineSettings(Settings): - """Offline mode default settings.""" - - load_pattern: Annotated[LoadPattern, cyclopts.Parameter(show=False)] = Field( - default_factory=lambda: LoadPattern(type=LoadPatternType.MAX_THROUGHPUT) - ) - - -class OnlineSettings(Settings): - """Online mode default settings.""" - - pass diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 47e11e7d5..ee84d35a5 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -83,7 +83,7 @@ settings: max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system - timeouts: # All global waits and deadlines (see config/timeouts.py) + timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index 825440624..b00743625 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -83,7 +83,7 @@ settings: max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system - timeouts: # All global waits and deadlines (see config/timeouts.py) + timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index f480473e1..cd3ebc552 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -84,7 +84,7 @@ settings: max_idle_time: 4.0 # Discard connections idle longer than this (seconds) min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system - timeouts: # All global waits and deadlines (see config/timeouts.py) + timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) diff --git a/src/inference_endpoint/config/timeouts.py b/src/inference_endpoint/config/timeouts.py deleted file mode 100644 index 1a6d2124d..000000000 --- a/src/inference_endpoint/config/timeouts.py +++ /dev/null @@ -1,134 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Global waits and deadlines (the ``settings.timeouts`` block). - -Split criterion: one module per config domain; every global time knob that -bounds how long the harness waits — startup readiness, per-phase drains, and -the whole-run watchdog — lives here. Workload durations -(``runtime.max_duration_ms``) are part of the benchmark definition, not waits, -and stay in ``runtime``. Client worker-lifecycle timeouts stay on -``settings.client`` (endpoint-client internals). Dataset-scoped time knobs -(e.g. agentic ``turn_timeout_s``) stay in their dataset config blocks. -""" - -from __future__ import annotations - -from typing import Annotated - -import cyclopts -from pydantic import BaseModel, ConfigDict, Field - -from ..utils import WithUpdatesMixin - - -@cyclopts.Parameter(name="*") -class Timeouts(WithUpdatesMixin, BaseModel): - """All global waits and deadlines. ``None`` = wait indefinitely / off. - - Reaching an optional deadline means something is stuck; ``run_timeout_s`` - is the whole-run watchdog — when it fires the run is aborted and the - report is marked INTERRUPTED. It never derives or caps the other - deadlines. Workload durations (``runtime.max_duration_ms``) are NOT - timeouts and do not live here. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - run_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--timeout", - help=( - "Whole-run watchdog in seconds (None = off). Firing aborts the " - "run and marks the report INTERRUPTED." - ), - ), - ] = Field( - None, - gt=0, - description=( - "Whole-run watchdog in seconds (None = off). Covers every phase " - "including drains; firing aborts the run, marks the report " - "INTERRUPTED, and exits non-zero. Never derives per-stage deadlines." - ), - ) - service_ready_timeout_s: Annotated[ - float, - cyclopts.Parameter( - alias="--service-ready-timeout", - help="Seconds to wait for metrics/event-logger services to start", - ), - ] = Field( - 30.0, - ge=0, - description="Seconds to wait for metrics-aggregator/event-logger services to become ready.", - ) - warmup_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--warmup-drain-timeout", - help="Warmup drain timeout in seconds (None = wait indefinitely)", - ), - ] = Field( - 240.0, - gt=0, - description="Warmup drain timeout in seconds (None = wait indefinitely)", - ) - performance_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--performance-drain-timeout", - help="Performance drain timeout in seconds (None = wait indefinitely)", - ), - ] = Field( - None, - gt=0, - description="Performance drain timeout in seconds (None = wait indefinitely)", - ) - accuracy_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--accuracy-drain-timeout", - help="Accuracy drain timeout in seconds (None = wait indefinitely)", - ), - ] = Field( - None, - gt=0, - description=( - "Accuracy drain timeout in seconds (None = wait indefinitely; " - "accuracy is unbounded by default because every sample must complete)" - ), - ) - metrics_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--metrics-drain-timeout", - help=( - "Wall-clock budget (seconds) for the metrics aggregator to finish " - "tokenizing buffered samples after the run ends " - "(None = wait indefinitely)" - ), - ), - ] = Field( - None, - gt=0, - description=( - "Wall-clock budget (seconds) to finish tokenizing buffered samples " - "after ENDED (None = wait indefinitely). An incomplete drain fails " - "the run: artifacts are written with complete: false, then " - "run_benchmark exits non-zero." - ), - ) diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index 86dd0a358..2c6f6b6d3 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -39,9 +39,9 @@ StreamingMode, TestMode, TestType, + Timeouts, WarmupConfig, ) -from inference_endpoint.config.timeouts import Timeouts from inference_endpoint.endpoint_client.config import HTTPClientConfig from inference_endpoint.exceptions import ExecutionError diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 7f2460dac..d50f305f8 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -71,6 +71,7 @@ StreamingMode, TestMode, TestType, + Timeouts, WarmupConfig, ) from inference_endpoint.config.schema import ( @@ -82,7 +83,6 @@ from inference_endpoint.config.schema import ( OnlineBenchmarkConfig as OnlineConfig, ) -from inference_endpoint.config.timeouts import Timeouts from inference_endpoint.config.utils import cli_error_formatter as _error_formatter from inference_endpoint.core.types import APIType, QueryResult from inference_endpoint.dataset_manager.dataset import Dataset @@ -2559,7 +2559,7 @@ def test_accuracy_only_setup_validates_with_non_default_api_type( ctx = self._setup( config, TestMode.ACC, - (_simple_dataset, [], []), + (_simple_dataset, []), _rt_settings, ) diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index 3abf8cd88..0ab78244a 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -28,8 +28,8 @@ LoadPatternType, RuntimeConfig, TestType, + Timeouts, ) -from inference_endpoint.config.timeouts import Timeouts from inference_endpoint.metrics.metric import Throughput from pydantic import ValidationError From e67db735003cfb478743b2d6288e0be559df58cf Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 13:34:14 -0700 Subject: [PATCH 11/45] fix(watchdog): bound the pre-session window and keep timeout attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local review-council findings (codex + claude, run against the PR diff): - A deadline expiring during service launch or endpoint connect used to SIGTERM the aggregator and then let the pending readiness awaits run out their own timeouts (30s+ past the deadline), surfacing as a 'service crashed during startup' RuntimeError instead of the run timeout. The watchdog now cancels the orchestration task while no session exists — pending awaits unwind immediately, MetricsPipeline __aexit__ kills the services — and _run_benchmark_async translates any exception unwinding after a fire into the run-timeout ExecutionError (KeyboardInterrupt/SystemExit excluded). New integration test pins the prompt-abort behavior. - A timed-out run whose aggregator finalized COMPLETE before SIGTERM landed kept state: complete with complete: false in its artifacts — the documented drain-timeout signature — misattributing a watchdog abort to a slow drain. The split-brain guard now also sets state: interrupted. - --timeout <= 0 in from-config re-validates the frozen Timeouts model after the config try/except, escaping as a raw pydantic traceback (exit 1); now mapped to InputValidationError (exit 2) like every other bad input, with a unit test. - _RunWatchdog docstring described the fire ordering aspirationally (session ENDED flushing tokenizer-drain samples into the aggregator before SIGTERM); rewritten to match the actual first-wins semantics. - CLI_QUICK_REFERENCE showed finalization inside the watchdog brace; it runs after watchdog.cancel() — the diagram and --timeout row now state that scoring/artifact writes are not deadline-bounded. - Stale pointers to schema modules abandoned with the deferred split (config/timeouts.py, config/settings.py, config/schema/settings.py) in the aggregator --help strings, DESIGN.md, DEVELOPMENT.md, and AGENTS.md now point at config/schema.py. - test_run_timeout_produces_interrupted_report ran with a 2 s budget that a slow host could burn during startup, silently exercising the pre-session path instead of the mid-run one; budget raised to 6 s. --- AGENTS.md | 4 +- docs/CLI_QUICK_REFERENCE.md | 37 ++++++----- docs/DEVELOPMENT.md | 2 +- .../services/metrics_aggregator/DESIGN.md | 2 +- docs/config/DESIGN.md | 22 +++---- .../services/metrics_aggregator/__main__.py | 4 +- .../commands/benchmark/cli.py | 15 +++-- .../commands/benchmark/execute.py | 66 ++++++++++++++----- src/inference_endpoint/config/schema.py | 5 +- .../integration/commands/test_run_timeout.py | 52 ++++++++++++++- tests/unit/commands/test_benchmark.py | 18 +++++ 11 files changed, 167 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6855d1378..fb5f84e52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Dataset Manager --> Load Generator --> Endpoint Client --> External Endpoint | **Dataset Manager** | `src/inference_endpoint/dataset_manager/` | Loads JSONL, HuggingFace, CSV, JSON, Parquet datasets. `Dataset` base class with `load_sample()`/`num_samples()` interface | | **Metrics Aggregator** | `src/inference_endpoint/async_utils/services/metrics_aggregator/` | Subprocess. Subscribes to events, aggregates per-sample metrics into a `MetricsRegistry` (counters + HDR-histogram series + raw values), publishes `MetricsSnapshot` over IPC PUB at a configurable cadence (`SessionState`: `INITIALIZE` → `LIVE` → `DRAINING` → {`COMPLETE` \| `INTERRUPTED`}). Final snapshot is atomically written to `final_snapshot.json` as the **primary** Report source; the terminal pub/sub frame is a TUI "run finished" signal only. | | **Report** | `src/inference_endpoint/metrics/report.py` | `Report.from_snapshot(dict)` — pure-function builder consuming the dict form (`snapshot_to_dict`). Reads `final_snapshot.json` directly via `json.loads` (no Struct decode). Plumbs `complete = (state == "complete" and n_pending_tasks == 0)`; renders an explicit warning for `INTERRUPTED` runs. | -| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema (`schema.py`) — the declared user surface, distinct from the resolved `runtime_settings.py`; `Timeouts` (`config/schema.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays in `settings.py`; client worker-lifecycle timeouts stay on `settings.client`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | +| **Config** | `src/inference_endpoint/config/`, `endpoint_client/config.py` | Pydantic-based YAML schema (`schema.py`) — the declared user surface, distinct from the resolved `runtime_settings.py`; `Timeouts` (`config/schema.py` — all give-up deadlines in one frozen model at `settings.timeouts`, incl. the whole-run `run_timeout_s` watchdog; the workload duration `runtime.max_duration_ms` stays on `settings.runtime`; client worker-lifecycle timeouts stay on `settings.client`), `HTTPClientConfig` (single Pydantic model for CLI/YAML/runtime), `RuntimeSettings` | | **CLI** | `src/inference_endpoint/main.py`, `commands/benchmark/cli.py` | cyclopts-based, auto-generated from `schema.py` and `HTTPClientConfig` Pydantic models. Flat shorthands via `cyclopts.Parameter(alias=...)` | | **Async Utils** | `src/inference_endpoint/async_utils/` | `LoopManager` (uvloop + eager_task_factory), ZMQ transport layer, generic `MessageCodec[T]`-parametrized pub/sub, event publisher | | **OpenAI/SGLang** | `src/inference_endpoint/openai/`, `sglang/` | Protocol adapters and response accumulators for different API formats. `openai_completions` adapter (`completions_adapter.py`) sends pre-tokenized token IDs to `/v1/completions`, bypassing the server chat template — required for gpt-oss-120b on vLLM. `sglang` adapter sends to `/generate` via `input_ids`. Both apply `Harmonize()` client-side. | @@ -310,7 +310,7 @@ All of these run automatically on commit: - `mypy` type checking - `prettier` for YAML/JSON/Markdown - License header enforcement -- `regenerate-templates`: auto-regenerates YAML config templates from schema defaults when any config schema module (`schema|audit|model_params|datasets|settings|timeouts`.py), `endpoint_client/config.py`, or `regenerate_templates.py` changes +- `regenerate-templates`: auto-regenerates YAML config templates from schema defaults when any `config/*.py` module, `endpoint_client/config.py`, or `regenerate_templates.py` changes **IMPORTANT: Always run `pre-commit run --all-files` before every commit.** Hooks may modify files (prettier, ruff-format, license headers). If files are modified, stage the changes and commit once. Never commit without running pre-commit first. diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 5c6a4dbf7..c5f56208c 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -137,7 +137,9 @@ run_benchmark ── run_timeout_s deadline captured here ─────── │ │ │ └─ performance_drain_timeout_s │ │ └───────────────┴─ max_duration_ms caps ISSUING only; reaching it │ │ ends the phase NORMALLY (valid report) and │ -│ SKIPS the drain — the two never run together │ +│ SKIPS the drain — both knobs may be set; the │ +│ drain timeout applies only when issuance │ +│ finishes before the cap │ │ │ ├─ ACCURACY issue ──────────┤ drain ─┤ ── accuracy_drain_timeout_s │ │ │ @@ -146,24 +148,29 @@ run_benchmark ── run_timeout_s deadline captured here ─────── │ complete: false + non-zero exit) │ ├─ worker shutdown ── client.worker_graceful_shutdown_wait│ │ then client.worker_force_kill_timeout -└─ finalize: score accuracy, write artifacts │ - │ +│ │ run_timeout_s (whole-run watchdog) ───────────────────────────────────────────────┘ firing at ANY point above aborts the run: report marked INTERRUPTED, non-zero exit +└─ finalize: score accuracy, write artifacts (OUTSIDE the watchdog: a timed-out + run SKIPS scoring, writes its + INTERRUPTED artifacts, exits + non-zero; scoring of a run that + finished within budget is not + deadline-bounded) ``` -| YAML path | CLI flag | Semantics | -| ----------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | -| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog over everything above; firing aborts the entire run — report marked INTERRUPTED, non-zero exit | -| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | -| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | -| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | -| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | -| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | -| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | -| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | +| YAML path | CLI flag | Semantics | +| ----------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | +| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | +| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | +| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | +| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | +| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | +| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | +| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | +| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | How the knobs compose: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index aef0f5c9d..8937e5fc0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -201,7 +201,7 @@ docs/short-description ## YAML Config Templates -Config templates in `src/inference_endpoint/config/templates/` are auto-generated from schema defaults. When you change any config schema module (`config/schema.py` and its sibling domain modules), regenerate them: +Config templates in `src/inference_endpoint/config/templates/` are auto-generated from schema defaults. When you change the config schema (`config/schema.py`), regenerate them: ```bash uv run python scripts/regenerate_templates.py diff --git a/docs/async_utils/services/metrics_aggregator/DESIGN.md b/docs/async_utils/services/metrics_aggregator/DESIGN.md index 58c9eb2f9..323372980 100644 --- a/docs/async_utils/services/metrics_aggregator/DESIGN.md +++ b/docs/async_utils/services/metrics_aggregator/DESIGN.md @@ -123,7 +123,7 @@ COMPLETE event ─► trigger.fire ─► queue.enqueue(text, on_count) [ `--drain-timeout` and `--tokenizer-workers` have service-side defaults (`0` and `2`) so the service is launchable by hand without tuning knobs, but the config schema is the single source of truth (`settings.timeouts.metrics_drain_timeout_s` -in `config/schema/settings.py`, `settings.metrics_tokenizer_workers` in `config/schema.py`): the benchmark always +in `config/schema.py`, `settings.metrics_tokenizer_workers` in `config/schema.py`): the benchmark always forwards the schema values (`--metrics-drain-timeout`, `--metrics-tokenizer-workers`), overriding these defaults in normal runs. diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index e302b2f1d..60458ce00 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -58,17 +58,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | -------------- | ----------------------------------------- | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int` | explicit, or dataset size (issue once) | -| `min_duration_ms` | `int \| None` | ruleset override path only (`UserConfig`) | -| `max_duration_ms` | `int \| None` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric` | primary target driving scheduler logic | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | -------------- | ------------------------------------------------------------------------------------------------ | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int \| None` | explicit, else dataset size; uses `target_qps` × `min_duration_ms` (padded) if a ruleset applies | +| `min_duration_ms` | `int \| None` | set only when a ruleset is applied (`UserConfig`) | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric` | primary target driving scheduler logic | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py index 4320c9fa0..d65bf3ab3 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py @@ -145,7 +145,7 @@ async def main() -> None: "Wall-clock budget (seconds) to finish tokenizing buffered samples " "after ENDED before the aggregator emits the final snapshot with " "n_pending_tasks > 0 (0 = wait indefinitely, the default; the " - "benchmark forwards the schema default, see config/timeouts.py). " + "benchmark forwards the schema default, see config/schema.py). " "Increase for very large datasets where the end-of-run tokenize " "batch is big." ), @@ -176,7 +176,7 @@ async def main() -> None: "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT " "(0 = no mid-run tokenization, everything defers to the " "end-of-run drain; the benchmark forwards the schema default, " - "see config/settings.py). The drain always uses the auto-sized " + "see config/schema.py). The drain always uses the auto-sized " "sharded pool — one worker process per 8-core block." ), ) diff --git a/src/inference_endpoint/commands/benchmark/cli.py b/src/inference_endpoint/commands/benchmark/cli.py index 68aaa0f1e..ca854b056 100644 --- a/src/inference_endpoint/commands/benchmark/cli.py +++ b/src/inference_endpoint/commands/benchmark/cli.py @@ -174,15 +174,18 @@ def from_config( except (yaml.YAMLError, ValidationError, ValueError, FileNotFoundError) as e: raise InputValidationError(f"Config error: {e}") from e if timeout is not None: - resolved = resolved.with_updates( - settings=resolved.settings.model_copy( - update={ - "timeouts": resolved.settings.timeouts.with_updates( + try: + resolved = resolved.with_updates( + settings=resolved.settings.with_updates( + timeouts=resolved.settings.timeouts.with_updates( run_timeout_s=timeout ) - } + ) ) - ) + except ValidationError as e: + # with_updates re-validates every layer (Timeouts is gt=0), so a + # bad --timeout must exit 2 like every other invalid input. + raise InputValidationError(f"Invalid --timeout: {e}") from e if report_dir is not None: resolved = resolved.with_updates(report_dir=report_dir) test_mode = mode or ( diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index f47a8c44a..6b5aa69c2 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -715,15 +715,19 @@ class _RunWatchdog: Armed before the metrics pipeline starts (so service-launch and endpoint-connect stalls are bounded) and kept armed through the metrics - drain (so a stuck aggregator drain is bounded too). On fire: stop the - session first — it short-circuits its drain and publishes ENDED promptly, - so the event logger flushes and the aggregator records the buffered - tokenizer-drain samples — then SIGTERM the aggregator, whose handler - writes the INTERRUPTED final snapshot (``publish_final`` is first-wins, - so INTERRUPTED stays authoritative). ``run_benchmark`` raises - ``ExecutionError`` after finalization whenever ``fired`` is set, so a - timed-out run always fails loudly even if a still-draining aggregator - finalized COMPLETE first. + drain (so a stuck aggregator drain is bounded too). On fire, once the + session exists: stop the session (its run unwinds and publishes ENDED, so + the event logger — spared the SIGTERM — flushes and exits) and SIGTERM + the aggregator, whose handler immediately writes the INTERRUPTED final + snapshot with whatever stats it holds at that instant (``publish_final`` + is first-wins, so INTERRUPTED stays authoritative). Before the session + exists (service launch / endpoint connect still pending), stopping + nothing would let those awaits run out their own readiness timeouts past + the deadline — so the orchestration task is cancelled instead, which + unwinds them promptly and lets ``MetricsPipeline.__aexit__`` kill the + services. ``run_benchmark`` raises ``ExecutionError`` after finalization + whenever ``fired`` is set, so a timed-out run always fails loudly even if + a still-draining aggregator finalized COMPLETE first. """ def __init__( @@ -734,6 +738,7 @@ def __init__( ) -> None: self.fired = False self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None self._pipe = pipe self._handle = ( loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) @@ -741,6 +746,10 @@ def __init__( else None ) + def bind_task(self, task: asyncio.Task | None) -> None: + """Bind the orchestration task — the pre-session cancellation target.""" + self._task = task + def bind_session(self, session: BenchmarkSession) -> None: """Late-bind the session: it is created after the timer is armed.""" self._session = session @@ -750,8 +759,17 @@ def _fire(self) -> None: logger.error( "Run timeout reached; aborting run — report will be marked " "INTERRUPTED." ) - if self._session is not None: - self._session.stop() + if self._session is None: + # Still in service launch / endpoint connect: cancel the + # orchestration task so those awaits unwind now instead of + # running out their own readiness timeouts past the deadline. + # No load was issued, so there are no artifacts to preserve; + # _run_benchmark_async translates the unwind into the run-timeout + # ExecutionError. + if self._task is not None: + self._task.cancel() + return + self._session.stop() self._pipe.terminate_metrics_aggregator() def cancel(self) -> None: @@ -893,6 +911,7 @@ async def _run_benchmark_async( http_client: HTTPEndpointClient | None = None watchdog = _RunWatchdog(loop, deadline, pipe) + watchdog.bind_task(asyncio.current_task()) try: tmpfs_dir.mkdir(parents=True, exist_ok=True) @@ -1054,15 +1073,27 @@ def _on_phase_start(phase: PhaseConfig) -> None: await http_client.shutdown_async() except Exception as e: # noqa: BLE001 — best-effort; idempotent logger.warning(f"Client cleanup error: {e}") - except BaseException: + except BaseException as e: if tmpfs_dir.exists(): try: _salvage_tmpfs(ctx.report_dir, tmpfs_dir) shutil.rmtree(tmpfs_dir, ignore_errors=True) - except Exception as e: # noqa: BLE001 — salvage best-effort; keep original exc + except Exception as salvage_err: # noqa: BLE001 — salvage best-effort; keep original exc logger.warning( - "Failed to salvage tmpfs: %s — tmpfs retained at %s", e, tmpfs_dir + "Failed to salvage tmpfs: %s — tmpfs retained at %s", + salvage_err, + tmpfs_dir, ) + if watchdog.fired and isinstance(e, Exception | asyncio.CancelledError): + # The watchdog aborted the run: the pre-session fire cancels this + # task, and a mid-teardown fire can surface as a launch/drain + # error (e.g. the SIGTERMed aggregator reads as a startup crash). + # Either way the run timed out — report it as that, not as the + # secondary exception. KeyboardInterrupt/SystemExit stay theirs. + raise ExecutionError( + "Run timeout (settings.timeouts.run_timeout_s) reached; run " + "aborted before a report could be produced" + ) from e raise finally: watchdog.cancel() @@ -1216,8 +1247,11 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: if report is not None and bench.run_timed_out and report.complete: # Split-brain guard: the aggregator may have finalized COMPLETE before # the watchdog's SIGTERM landed. A timed-out run must never publish - # complete:true artifacts, so force the flag honest before writing. - report = msgspec.structs.replace(report, complete=False) + # complete:true artifacts, so force both fields honest before writing — + # state stays what the SIGTERM path would have recorded, and consumers + # keying on state=="complete" and not complete (the drain-timeout + # signature) don't misattribute a watchdog abort to a slow drain. + report = msgspec.structs.replace(report, complete=False, state="interrupted") # Write scoring artifacts + copy event log from tmpfs to disk (scorers read # sample_idx_map.json + events.jsonl from here). diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 30e1171db..059451d03 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -147,8 +147,7 @@ class ScorerMethod(str, Enum): # --------------------------------------------------------------------- audit # The root-level ``audit:`` block: per-test config models and their id enum. -# The runnable test registry lives in ``compliance/``; these are small enough -# to live beside the root aggregate they plug into. +# The runnable test registry lives in ``compliance/``. class AuditTestId(str, Enum): """Registered compliance audit test identifiers.""" @@ -955,7 +954,7 @@ class EarlyStoppingConfig(BaseModel): @cyclopts.Parameter(name="*") -class Settings(BaseModel): +class Settings(WithUpdatesMixin, BaseModel): """Test settings.""" model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index 2c6f6b6d3..79f422b6c 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -22,10 +22,15 @@ """ import json +import time from pathlib import Path import pytest -from inference_endpoint.commands.benchmark.execute import run_benchmark +from inference_endpoint.commands.benchmark.execute import ( + run_benchmark, + run_benchmark_async, + setup_benchmark, +) from inference_endpoint.config.schema import ( BenchmarkConfig, Dataset, @@ -78,9 +83,12 @@ def test_run_timeout_produces_interrupted_report( load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), client=_FAST_CLIENT, # 600 samples at 5 QPS is a ~120 s workload, so only the watchdog - # can end the run. + # can end the run. The budget must comfortably exceed service + + # worker startup (a fire before the session exists aborts the + # launch instead, without mid-run artifacts — a different path, + # covered by test_run_timeout_during_service_launch_aborts_promptly). runtime=RuntimeConfig(n_samples_to_issue=600), - timeouts=Timeouts(run_timeout_s=2.0), + timeouts=Timeouts(run_timeout_s=6.0), warmup=WarmupConfig(enabled=False), ), ) @@ -217,3 +225,41 @@ def test_metrics_drain_timeout_fails_run(mock_http_echo_server, tmp_path): summary = _read_result_summary(report_dir) assert summary["complete"] is False + + +@pytest.mark.integration +def test_run_timeout_during_service_launch_aborts_promptly( + mock_http_echo_server, ds_dataset_path, tmp_path +): + """A deadline expiring before the session exists cancels the launch. + + The watchdog's pre-session fire path cancels the orchestration task so + pending service-launch/endpoint-connect awaits unwind immediately instead + of running out their own readiness timeouts, and the abort is attributed + to the run timeout (ExecutionError), not to a secondary launch error. + """ + config = BenchmarkConfig( + type=TestType.ONLINE, + endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), + model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), + datasets=[Dataset(path=str(ds_dataset_path), type=DatasetType.PERFORMANCE)], + report_dir=tmp_path, + settings=Settings( + load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), + client=_FAST_CLIENT, + runtime=RuntimeConfig(n_samples_to_issue=10), + warmup=WarmupConfig(enabled=False), + ), + ) + ctx = setup_benchmark(config, TestMode.PERF) + + start = time.monotonic() + # An already-expired deadline fires the watchdog on the first event-loop + # iteration — deterministically before the metrics services report ready. + with pytest.raises(ExecutionError, match="Run timeout"): + run_benchmark_async(ctx, deadline=time.monotonic()) + elapsed = time.monotonic() - start + + # Prompt unwind: nowhere near the 30 s service_ready_timeout_s the + # pre-fix behavior would have waited out. + assert elapsed < 15.0, f"launch abort took {elapsed:.1f}s" diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index d50f305f8..81bbe6c19 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -505,6 +505,24 @@ def test_from_config_handler(self, mock_run, tmp_path): assert called_config.settings.timeouts.run_timeout_s == 42.0 assert called_mode == TestMode.BOTH + @pytest.mark.unit + def test_from_config_rejects_non_positive_timeout(self, tmp_path): + yaml_content = """ +type: "offline" +model_params: + name: "test-model" +endpoint_config: + endpoints: ["http://test:8000"] +datasets: + - path: "test.jsonl" +""" + config_file = tmp_path / "test.yaml" + config_file.write_text(yaml_content) + # Timeouts.run_timeout_s is gt=0; a bad --timeout must be a clean + # input error (exit 2), not a raw pydantic traceback. + with pytest.raises(InputValidationError, match="Invalid --timeout"): + from_config(config=config_file, timeout=0.0) + @pytest.mark.unit @patch("inference_endpoint.commands.benchmark.cli.run_benchmark") def test_from_config_report_dir_override(self, mock_run, tmp_path): From f7ad3aca45882bf427292abc1603496dfc81d201 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:20:03 -0700 Subject: [PATCH 12/45] =?UTF-8?q?refactor(timeouts):=20one=20convention=20?= =?UTF-8?q?=E2=80=94=20None=20=3D=20unlimited,=20explicit=200=20=3D=20zero?= =?UTF-8?q?=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregator argv kept a second sentinel (0 = wait indefinitely, converted from the schema's None at the argv boundary), which made an explicit zero drain budget unexpressible and forced every reader to hold two conventions. Now None/omitted flag = unlimited everywhere and 0 is an honest zero-second budget (give up immediately): - --drain-timeout defaults to None (absent = unlimited) and is passed through verbatim; the benchmark omits the flag when metrics_drain_timeout_s is None. - metrics_drain_timeout_s relaxes gt=0 to ge=0 so an explicit 0 is accepted; flush_remaining already treats 0 as an immediate deadline. - snapshot.py/AGENTS.md drop the argv-conversion caveat. --- AGENTS.md | 2 +- .../services/metrics_aggregator/__main__.py | 9 +++--- .../services/metrics_aggregator/snapshot.py | 2 +- .../commands/benchmark/pipeline.py | 9 +++--- src/inference_endpoint/config/schema.py | 26 +++++++++-------- .../templates/concurrency_template_full.yaml | 8 ++--- .../templates/offline_template_full.yaml | 8 ++--- .../templates/online_template_full.yaml | 8 ++--- tests/unit/commands/test_benchmark.py | 19 +++++++----- tests/unit/config/test_timeouts.py | 29 +++++++++++++++---- 10 files changed, 71 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fb5f84e52..d9ba7a6d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. - **Series storage**: each `SeriesSampler` keeps three parallel views: O(1) cheap rollups (count/total/min/max/sum_sq, exact), an HDR Histogram (cheap live percentiles), and an in-memory `array.array` of raw values (for exact percentiles in the `COMPLETE` snapshot). Hot path is `registry.record(name, value)` — no allocation, no I/O. - **Counter API**: `registry.increment(name, delta=1)` for sample-event counters. `registry.set_counter(name, value)` only for the three derived-duration counters (`total_duration_ns` max-of-elapsed, `tracked_duration_ns` sum-of-blocks, `legacy_loadgen_window_duration_ns` first-issue→last-issued-completion span for LoadGen-parity QPS/TPS). -- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget — argv 0 = unlimited; schema `settings.timeouts.metrics_drain_timeout_s` uses None = unlimited, converted at the argv boundary) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. +- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget / `settings.timeouts.metrics_drain_timeout_s`: None or omitted flag = unlimited, 0 = give up immediately) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. - **Final delivery is dual-path with separated concerns**: `publish_final` atomically writes `final_snapshot.json` (`tmp + fsync(file) + rename + fsync(parent_dir)`) — this is the **primary** Report source — AND emits the terminal-state snapshot over pub/sub as a TUI shutdown signal. Each path is wrapped in its own try/except so one failure cannot suppress the other. Main process consumer reads `final_snapshot.json` (via `json.loads` to dict, no Struct decode); falls back to the subscriber's `latest` live snapshot only if the file is missing (e.g. SIGKILL / OOM before the signal handler ran). The dict form is the canonical consumer contract (see `snapshot_to_dict`). - **Early stopping (on by default)**: series registered with `register_series(..., tail_latency=True)` (today ttft/tpot/latency) get MLPerf early-stopping percentile estimates on the COMPLETE (exact) snapshot — a compact `early_stopping_percentiles` map in `result_summary.json` whose keys mirror the `percentiles` grid (≥ p50) with estimate-or-`null` values; rich detail is INFO-logged. On by default (cold-path only; the exact path shares one in-place sort between the percentile grid and the estimates); `settings.early_stopping.enabled: false` / `--no-early-stopping` opts out. Confidence/tolerance are LoadGen constants. Pure math in `metrics/early_stopping.py`; post-hoc recomputation from any run's `events.jsonl` via `scripts/early_stopping_estimate_from_events.py`. See docs/early_stopping.md. - **Histogram bucket edges are dynamic per snapshot**: log-spaced over the observed `[min, max]`. Bucket count is fixed at construction; consumers MUST re-render from the snapshot's `(lo, hi, count)` triples each frame and MUST NOT track bucket-by-index across snapshots. diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py index d65bf3ab3..de0324e85 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/__main__.py @@ -140,12 +140,13 @@ async def main() -> None: parser.add_argument( "--drain-timeout", type=float, - default=0.0, + default=None, help=( "Wall-clock budget (seconds) to finish tokenizing buffered samples " "after ENDED before the aggregator emits the final snapshot with " - "n_pending_tasks > 0 (0 = wait indefinitely, the default; the " - "benchmark forwards the schema default, see config/schema.py). " + "n_pending_tasks > 0. Omit to wait indefinitely (the default); " + "0 gives up immediately. Mirrors " + "settings.timeouts.metrics_drain_timeout_s (see config/schema.py). " "Increase for very large datasets where the end-of-run tokenize " "batch is big." ), @@ -273,7 +274,7 @@ async def main() -> None: ), streaming=args.streaming, shutdown_event=shutdown_event, - drain_timeout_s=None if args.drain_timeout == 0 else args.drain_timeout, + drain_timeout_s=args.drain_timeout, ) aggregator.start() diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py index 8284bdf0a..af24e7f88 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py @@ -45,7 +45,7 @@ class SessionState(str, Enum): LIVE → run in progress; tick task publishing live HDR-derived stats. DRAINING → ``SessionEventType.ENDED`` has been received; the aggregator is tokenizing the buffered samples (bounded by the - ``--drain-timeout`` budget — argv 0 = unlimited; the schema knob ``settings.timeouts.metrics_drain_timeout_s`` uses None, converted at the argv boundary). Tick task + ``--drain-timeout`` budget / ``settings.timeouts.metrics_drain_timeout_s``: None or omitted = unlimited, 0 = give up immediately). Tick task continues at this stage, still HDR-derived; no new events will arrive. COMPLETE → terminal clean state. The ``publish_final()`` snapshot diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index bd056ac48..756331f88 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -121,11 +121,10 @@ def _build_aggregator_args( args.append("--early-stopping") if tokenizer_name is not None: args.extend(["--tokenizer", tokenizer_name]) - # Aggregator argv contract keeps 0 = unlimited (hand-launch default); - # the schema uses None = unlimited, so convert at the argv boundary. - args.extend( - ["--drain-timeout", "0" if drain_timeout_s is None else str(drain_timeout_s)] - ) + # One convention everywhere: None = unlimited (flag omitted; also the + # aggregator's hand-launch default), an explicit 0 = zero budget. + if drain_timeout_s is not None: + args.extend(["--drain-timeout", str(drain_timeout_s)]) args.extend(["--tokenizer-workers", str(tokenizer_workers)]) return args diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 059451d03..9fc6f2687 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -809,36 +809,37 @@ class Timeouts(WithUpdatesMixin, BaseModel): float | None, cyclopts.Parameter( alias="--warmup-drain-timeout", - help="Warmup drain timeout in seconds (None = wait indefinitely)", + help="Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ), ] = Field( 240.0, - gt=0, - description="Warmup drain timeout in seconds (None = wait indefinitely)", + ge=0, + description="Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ) performance_drain_timeout_s: Annotated[ float | None, cyclopts.Parameter( alias="--performance-drain-timeout", - help="Performance drain timeout in seconds (None = wait indefinitely)", + help="Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ), ] = Field( None, - gt=0, - description="Performance drain timeout in seconds (None = wait indefinitely)", + ge=0, + description="Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ) accuracy_drain_timeout_s: Annotated[ float | None, cyclopts.Parameter( alias="--accuracy-drain-timeout", - help="Accuracy drain timeout in seconds (None = wait indefinitely)", + help="Accuracy drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ), ] = Field( None, - gt=0, + ge=0, description=( "Accuracy drain timeout in seconds (None = wait indefinitely; " - "accuracy is unbounded by default because every sample must complete)" + "0 = skip the drain; accuracy is unbounded by default because " + "every sample must complete)" ), ) metrics_drain_timeout_s: Annotated[ @@ -848,15 +849,16 @@ class Timeouts(WithUpdatesMixin, BaseModel): help=( "Wall-clock budget (seconds) for the metrics aggregator to finish " "tokenizing buffered samples after the run ends " - "(None = wait indefinitely)" + "(None = wait indefinitely; 0 = give up immediately)" ), ), ] = Field( None, - gt=0, + ge=0, description=( "Wall-clock budget (seconds) to finish tokenizing buffered samples " - "after ENDED (None = wait indefinitely). An incomplete drain fails " + "after ENDED (None = wait indefinitely; 0 = give up immediately). " + "An incomplete drain fails " "the run: artifacts are written with complete: false, then " "run_benchmark exits non-zero." ), diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index ee84d35a5..9e37525f3 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -86,10 +86,10 @@ settings: timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. - warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) - metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. + warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) + performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; 0 = skip the drain; accuracy is unbounded by default because every sample must complete) + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely; 0 = give up immediately). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index b00743625..be2d86c9e 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -86,10 +86,10 @@ settings: timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. - warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) - metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. + warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) + performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; 0 = skip the drain; accuracy is unbounded by default because every sample must complete) + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely; 0 = give up immediately). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index cd3ebc552..4c6f4e506 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -87,10 +87,10 @@ settings: timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. - warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely) - performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely) - accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; accuracy is unbounded by default because every sample must complete) - metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. + warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) + performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) + accuracy_drain_timeout_s: null # Accuracy drain timeout in seconds (None = wait indefinitely; 0 = skip the drain; accuracy is unbounded by default because every sample must complete) + metrics_drain_timeout_s: null # Wall-clock budget (seconds) to finish tokenizing buffered samples after ENDED (None = wait indefinitely; 0 = give up immediately). An incomplete drain fails the run: artifacts are written with complete: false, then run_benchmark exits non-zero. warmup: enabled: false # Enable warmup phase before performance run n_requests: null # Warmup request count (None = full dataset once) diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 81bbe6c19..d7f0a8294 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -1060,7 +1060,7 @@ def _make_ctx(self, config, tmp_path): @pytest.mark.asyncio @pytest.mark.parametrize( "timeout_s, expected_flag", - [(120.0, "120.0"), (None, "0"), (60.0, "60.0")], + [(120.0, "120.0"), (None, None), (0.0, "0.0"), (60.0, "60.0")], ) async def test_drain_timeout_forwarded_to_aggregator_args( self, tmp_path, timeout_s, expected_flag @@ -1109,13 +1109,17 @@ async def _capture_launch(service_configs, *, timeout): aggregator_cfg = next(c for c in captured if "metrics_aggregator" in c.module) args = aggregator_cfg.args - assert "--drain-timeout" in args - idx = args.index("--drain-timeout") - assert args[idx + 1] == expected_flag + if expected_flag is None: + # None = unlimited: the flag is omitted; the aggregator's own + # default (no --drain-timeout) is also unlimited. + assert "--drain-timeout" not in args + else: + idx = args.index("--drain-timeout") + assert args[idx + 1] == expected_flag @pytest.mark.unit - def test_none_drain_timeout_builds_unlimited_argv(self): - """None (= unlimited) must cross the argv boundary as "0", never "None".""" + def test_none_drain_timeout_omits_flag(self): + """None (= unlimited) omits --drain-timeout; 0 means a zero budget.""" args = _build_aggregator_args( socket_dir="/tmp/sockets", pub_socket_name="pub", @@ -1127,8 +1131,7 @@ def test_none_drain_timeout_builds_unlimited_argv(self): tokenizer_workers=2, early_stopping=False, ) - idx = args.index("--drain-timeout") - assert args[idx + 1] == "0" + assert "--drain-timeout" not in args @pytest.mark.unit @pytest.mark.asyncio diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index 0ab78244a..170c00cc4 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -64,22 +64,39 @@ def test_metrics_tokenizer_workers_is_flat_settings_field(self): class TestTimeoutsValidation: + @pytest.mark.unit + @pytest.mark.parametrize( + "field, value", + [ + # A zero-length run budget is certainly a mistake: rejected, never + # reinterpreted. Drain budgets accept an honest 0 (skip the drain) + # and reject only negatives. + ("run_timeout_s", 0), + ("run_timeout_s", -1.0), + ("warmup_drain_timeout_s", -1.0), + ("performance_drain_timeout_s", -1.0), + ("accuracy_drain_timeout_s", -1.0), + ("metrics_drain_timeout_s", -1.0), + ], + ) + def test_deadline_must_be_positive_or_none(self, field, value): + # The 0-sentinel is dead: unlimited is spelled None, never 0. + with pytest.raises(ValidationError): + Timeouts(**{field: value}) + @pytest.mark.unit @pytest.mark.parametrize( "field", [ - "run_timeout_s", "warmup_drain_timeout_s", "performance_drain_timeout_s", "accuracy_drain_timeout_s", "metrics_drain_timeout_s", ], ) - @pytest.mark.parametrize("value", [0, -1.0]) - def test_deadline_must_be_positive_or_none(self, field, value): - # The 0-sentinel is dead: unlimited is spelled None, never 0. - with pytest.raises(ValidationError): - Timeouts(**{field: value}) + def test_zero_drain_budget_is_valid(self, field): + """One convention: None = unlimited, an explicit 0 = zero budget.""" + assert getattr(Timeouts(**{field: 0}), field) == 0 @pytest.mark.unit @pytest.mark.parametrize( From 98b3c7f5b43a965a51606366596b0d4da1002d47 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:22:43 -0700 Subject: [PATCH 13/45] refactor(execute): move the deadline timers into commands/benchmark/watchdog.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute.py had grown to ~1390 lines; the two event-loop timers — PerfPhaseTimeout (runtime.max_duration_ms perf-phase cap) and RunWatchdog (settings.timeouts.run_timeout_s whole-run deadline) — are a self-contained unit with their own semantics, so they get their own module. Public names now that they cross a module boundary; execute.py remains the only consumer. MetricsPipeline is imported under TYPE_CHECKING to keep the pipeline<->watchdog edge type-only. --- AGENTS.md | 1 + .../commands/benchmark/execute.py | 112 +------------- .../commands/benchmark/watchdog.py | 138 ++++++++++++++++++ tests/unit/commands/test_benchmark.py | 12 +- .../unit/load_generator/test_async_session.py | 2 +- 5 files changed, 153 insertions(+), 112 deletions(-) create mode 100644 src/inference_endpoint/commands/benchmark/watchdog.py diff --git a/AGENTS.md b/AGENTS.md index d9ba7a6d1..f364bae55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,7 @@ src/inference_endpoint/ │ │ ├── __init__.py │ │ ├── cli.py # benchmark_app: offline, online, from-config subcommands │ │ ├── execute.py # Phased orchestration: setup_benchmark/run_benchmark_async/finalize_benchmark + BenchmarkContext; run_benchmark runs the main benchmark (cli._run dispatches run_audit when audit: is set) +│ │ ├── watchdog.py # PerfPhaseTimeout (perf-phase cap) + RunWatchdog (whole-run deadline) event-loop timers │ │ ├── profiling.py # Profiler-trigger protocol (vLLM /start_profile,/stop_profile) + ProfileController (URL derivation + start/stop/payload lifecycle) │ │ ├── accuracy.py # AccuracyConfiguration + per-dataset scoring (_score_accuracy, OSL/response-count rollups, write_accuracy_results) │ │ └── pipeline.py # MetricsPipeline: async context manager for the ZMQ + metrics-aggregator/event-logger subprocess lifecycle (__aenter__/__aexit__/start/drain_and_build_report) + snapshot→Report diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 6b5aa69c2..6b5416a2f 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -62,6 +62,10 @@ ProfileController, write_profiling_section, ) +from inference_endpoint.commands.benchmark.watchdog import ( + PerfPhaseTimeout, + RunWatchdog, +) from inference_endpoint.compliance import AuditRunSpec from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( @@ -676,108 +680,6 @@ def _build_phases( return phases -class _PerfPhaseTimeout: - """Session-stop timer that bounds the PERFORMANCE phase only. - - ``max_duration_ms`` is a safety cap on the performance phase. The timer is - armed when the performance phase starts and cancelled as soon as any later - phase starts, so it can never truncate a subsequent accuracy phase: a - combined perf+accuracy run must let accuracy finish regardless of how long - perf ran. - """ - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - max_duration_ms: int | None, - on_timeout: Callable[[], None], - ) -> None: - self._loop = loop - self._max_duration_ms = max_duration_ms - self._on_timeout = on_timeout - self._handle: asyncio.TimerHandle | None = None - - def on_phase_start(self, phase_type: PhaseType) -> None: - self.cancel() - if phase_type == PhaseType.PERFORMANCE and self._max_duration_ms is not None: - self._handle = self._loop.call_later( - self._max_duration_ms / 1000.0, self._on_timeout - ) - - def cancel(self) -> None: - if self._handle is not None: - self._handle.cancel() - self._handle = None - - -class _RunWatchdog: - """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. - - Armed before the metrics pipeline starts (so service-launch and - endpoint-connect stalls are bounded) and kept armed through the metrics - drain (so a stuck aggregator drain is bounded too). On fire, once the - session exists: stop the session (its run unwinds and publishes ENDED, so - the event logger — spared the SIGTERM — flushes and exits) and SIGTERM - the aggregator, whose handler immediately writes the INTERRUPTED final - snapshot with whatever stats it holds at that instant (``publish_final`` - is first-wins, so INTERRUPTED stays authoritative). Before the session - exists (service launch / endpoint connect still pending), stopping - nothing would let those awaits run out their own readiness timeouts past - the deadline — so the orchestration task is cancelled instead, which - unwinds them promptly and lets ``MetricsPipeline.__aexit__`` kill the - services. ``run_benchmark`` raises ``ExecutionError`` after finalization - whenever ``fired`` is set, so a timed-out run always fails loudly even if - a still-draining aggregator finalized COMPLETE first. - """ - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - deadline: float | None, - pipe: MetricsPipeline, - ) -> None: - self.fired = False - self._session: BenchmarkSession | None = None - self._task: asyncio.Task | None = None - self._pipe = pipe - self._handle = ( - loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) - if deadline is not None - else None - ) - - def bind_task(self, task: asyncio.Task | None) -> None: - """Bind the orchestration task — the pre-session cancellation target.""" - self._task = task - - def bind_session(self, session: BenchmarkSession) -> None: - """Late-bind the session: it is created after the timer is armed.""" - self._session = session - - def _fire(self) -> None: - self.fired = True - logger.error( - "Run timeout reached; aborting run — report will be marked " "INTERRUPTED." - ) - if self._session is None: - # Still in service launch / endpoint connect: cancel the - # orchestration task so those awaits unwind now instead of - # running out their own readiness timeouts past the deadline. - # No load was issued, so there are no artifacts to preserve; - # _run_benchmark_async translates the unwind into the run-timeout - # ExecutionError. - if self._task is not None: - self._task.cancel() - return - self._session.stop() - self._pipe.terminate_metrics_aggregator() - - def cancel(self) -> None: - if self._handle is not None: - self._handle.cancel() - self._handle = None - - async def _create_issuer( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop ) -> tuple[HttpClientSampleIssuer, HTTPEndpointClient]: @@ -910,7 +812,7 @@ async def _run_benchmark_async( # idempotent, so the clean-path shutdown below is a harmless second call. http_client: HTTPEndpointClient | None = None - watchdog = _RunWatchdog(loop, deadline, pipe) + watchdog = RunWatchdog(loop, deadline, pipe) watchdog.bind_task(asyncio.current_task()) try: @@ -972,12 +874,12 @@ def _on_global_timeout() -> None: # perf cap. session.stop_current_phase() - perf_timeout = _PerfPhaseTimeout( + perf_timeout = PerfPhaseTimeout( loop, max_duration_ms, _on_global_timeout ) def _on_phase_start(phase: PhaseConfig) -> None: - # _PerfPhaseTimeout arms the perf cap on PERFORMANCE and cancels + # PerfPhaseTimeout arms the perf cap on PERFORMANCE and cancels # it when any later phase starts, so a combined perf+accuracy run # can never have its accuracy phase truncated by the perf cap. perf_timeout.on_phase_start(phase.phase_type) diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py new file mode 100644 index 000000000..a5fcd65e5 --- /dev/null +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -0,0 +1,138 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run-scoped deadline timers for the benchmark orchestrator. + +``PerfPhaseTimeout`` bounds the PERFORMANCE phase (``runtime.max_duration_ms``); +``RunWatchdog`` is the whole-run deadline (``settings.timeouts.run_timeout_s``). +Both are event-loop timers owned by ``commands/benchmark/execute.py``. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Callable +from typing import TYPE_CHECKING + +from inference_endpoint.load_generator.session import BenchmarkSession, PhaseType + +if TYPE_CHECKING: + from inference_endpoint.commands.benchmark.pipeline import MetricsPipeline + +logger = logging.getLogger(__name__) + + +class PerfPhaseTimeout: + """Session-stop timer that bounds the PERFORMANCE phase only. + + ``max_duration_ms`` is a safety cap on the performance phase. The timer is + armed when the performance phase starts and cancelled as soon as any later + phase starts, so it can never truncate a subsequent accuracy phase: a + combined perf+accuracy run must let accuracy finish regardless of how long + perf ran. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + max_duration_ms: int | None, + on_timeout: Callable[[], None], + ) -> None: + self._loop = loop + self._max_duration_ms = max_duration_ms + self._on_timeout = on_timeout + self._handle: asyncio.TimerHandle | None = None + + def on_phase_start(self, phase_type: PhaseType) -> None: + self.cancel() + if phase_type == PhaseType.PERFORMANCE and self._max_duration_ms is not None: + self._handle = self._loop.call_later( + self._max_duration_ms / 1000.0, self._on_timeout + ) + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None + + +class RunWatchdog: + """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. + + Armed before the metrics pipeline starts (so service-launch and + endpoint-connect stalls are bounded) and kept armed through the metrics + drain (so a stuck aggregator drain is bounded too). On fire, once the + session exists: stop the session (its run unwinds and publishes ENDED, so + the event logger — spared the SIGTERM — flushes and exits) and SIGTERM + the aggregator, whose handler immediately writes the INTERRUPTED final + snapshot with whatever stats it holds at that instant (``publish_final`` + is first-wins, so INTERRUPTED stays authoritative). Before the session + exists (service launch / endpoint connect still pending), stopping + nothing would let those awaits run out their own readiness timeouts past + the deadline — so the orchestration task is cancelled instead, which + unwinds them promptly and lets ``MetricsPipeline.__aexit__`` kill the + services. ``run_benchmark`` raises ``ExecutionError`` after finalization + whenever ``fired`` is set, so a timed-out run always fails loudly even if + a still-draining aggregator finalized COMPLETE first. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + deadline: float | None, + pipe: MetricsPipeline, + ) -> None: + self.fired = False + self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None + self._pipe = pipe + self._handle = ( + loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) + if deadline is not None + else None + ) + + def bind_task(self, task: asyncio.Task | None) -> None: + """Bind the orchestration task — the pre-session cancellation target.""" + self._task = task + + def bind_session(self, session: BenchmarkSession) -> None: + """Late-bind the session: it is created after the timer is armed.""" + self._session = session + + def _fire(self) -> None: + self.fired = True + logger.error( + "Run timeout reached; aborting run — report will be marked " "INTERRUPTED." + ) + if self._session is None: + # Still in service launch / endpoint connect: cancel the + # orchestration task so those awaits unwind now instead of + # running out their own readiness timeouts past the deadline. + # No load was issued, so there are no artifacts to preserve; + # _run_benchmark_async translates the unwind into the run-timeout + # ExecutionError. + if self._task is not None: + self._task.cancel() + return + self._session.stop() + self._pipe.terminate_metrics_aggregator() + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index d7f0a8294..061738269 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -43,7 +43,6 @@ ResponseCollector, _build_phases, _load_datasets, - _PerfPhaseTimeout, _run_benchmark_async, finalize_benchmark, setup_benchmark, @@ -56,6 +55,7 @@ _render_profile_status, write_profiling_section, ) +from inference_endpoint.commands.benchmark.watchdog import PerfPhaseTimeout from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( AgenticInferenceConfig, @@ -2731,7 +2731,7 @@ class TestPerfPhaseTimeout: def test_armed_on_performance_phase(self): loop = _FakeLoop() fired: list[bool] = [] - timeout = _PerfPhaseTimeout(loop, 4000, lambda: fired.append(True)) + timeout = PerfPhaseTimeout(loop, 4000, lambda: fired.append(True)) timeout.on_phase_start(PhaseType.PERFORMANCE) @@ -2745,7 +2745,7 @@ def test_armed_on_performance_phase(self): @pytest.mark.unit def test_cancelled_when_accuracy_phase_starts(self): loop = _FakeLoop() - timeout = _PerfPhaseTimeout(loop, 4000, lambda: None) + timeout = PerfPhaseTimeout(loop, 4000, lambda: None) timeout.on_phase_start(PhaseType.PERFORMANCE) perf_handle = loop.scheduled[0][2] @@ -2758,7 +2758,7 @@ def test_cancelled_when_accuracy_phase_starts(self): @pytest.mark.unit def test_not_armed_without_max_duration(self): loop = _FakeLoop() - timeout = _PerfPhaseTimeout(loop, None, lambda: None) + timeout = PerfPhaseTimeout(loop, None, lambda: None) timeout.on_phase_start(PhaseType.PERFORMANCE) @@ -2767,7 +2767,7 @@ def test_not_armed_without_max_duration(self): @pytest.mark.unit def test_not_armed_for_non_performance_phase(self): loop = _FakeLoop() - timeout = _PerfPhaseTimeout(loop, 4000, lambda: None) + timeout = PerfPhaseTimeout(loop, 4000, lambda: None) timeout.on_phase_start(PhaseType.WARMUP) timeout.on_phase_start(PhaseType.ACCURACY) @@ -2777,7 +2777,7 @@ def test_not_armed_for_non_performance_phase(self): @pytest.mark.unit def test_cancel_is_idempotent(self): loop = _FakeLoop() - timeout = _PerfPhaseTimeout(loop, 4000, lambda: None) + timeout = PerfPhaseTimeout(loop, 4000, lambda: None) timeout.cancel() # no handle yet — must not raise timeout.on_phase_start(PhaseType.PERFORMANCE) diff --git a/tests/unit/load_generator/test_async_session.py b/tests/unit/load_generator/test_async_session.py index 7daf05f10..cfdf697dd 100644 --- a/tests/unit/load_generator/test_async_session.py +++ b/tests/unit/load_generator/test_async_session.py @@ -537,7 +537,7 @@ async def test_stop_terminates_early(self): async def test_stop_current_phase_advances_to_accuracy(self): """A perf-phase timeout must end only that phase, not skip accuracy. - Mirrors _PerfPhaseTimeout firing mid-perf: stop_current_phase cancels + Mirrors PerfPhaseTimeout firing mid-perf: stop_current_phase cancels the perf strategy without setting the session-wide stop flag, so the following accuracy phase still runs to completion. """ From d24ea0a5b8309558bb332e02d538e21bd0655d2e Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:26:41 -0700 Subject: [PATCH 14/45] style(execute): walrus the run_timeout_s deadline resolutions --- src/inference_endpoint/commands/benchmark/execute.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 6b5416a2f..ba46155dd 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -1019,10 +1019,11 @@ def run_benchmark_async( computes its own deadline at entry, so each audit phase gets a full per-phase budget. """ - if deadline is None: - run_timeout_s = ctx.config.settings.timeouts.run_timeout_s - if run_timeout_s is not None: - deadline = time.monotonic() + run_timeout_s + if ( + deadline is None + and (run_timeout_s := ctx.config.settings.timeouts.run_timeout_s) is not None + ): + deadline = time.monotonic() + run_timeout_s loop = LoopManager().default_loop return loop.run_until_complete(_run_benchmark_async(ctx, loop, deadline=deadline)) @@ -1234,8 +1235,7 @@ def run_benchmark( # Deadline for the whole-run watchdog is taken at entry so setup # (tokenizer/dataset load) counts against run_timeout_s too. deadline: float | None = None - run_timeout_s = config.settings.timeouts.run_timeout_s - if run_timeout_s is not None: + if (run_timeout_s := config.settings.timeouts.run_timeout_s) is not None: deadline = time.monotonic() + run_timeout_s ctx = setup_benchmark(config, test_mode) if deadline is not None and time.monotonic() >= deadline: From 4820c2daacf690044710eba18248d71dae4ba254 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:30:06 -0700 Subject: [PATCH 15/45] refactor(watchdog): fold the pre-session stop into bind_session; rename launcher.terminate to terminate_module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bind_session now stops an already-fired session itself, collapsing the 15-line fired branch at the session.run call site to a one-line hook selection — the remaining watchdog.fired checks each guard a distinct data-preservation step (teardown-race swallow so finalize still writes INTERRUPTED artifacts, timeout attribution on unwind, post-finalize raise) and stay. ServiceLauncher.terminate(module) read like a general kill-everything API; terminate_module says what it selects on and pairs with terminate_all. --- .../async_utils/services/launcher.py | 2 +- .../commands/benchmark/execute.py | 25 ++++++++----------- .../commands/benchmark/pipeline.py | 2 +- .../commands/benchmark/watchdog.py | 10 +++++++- .../async_utils/services/test_launcher.py | 6 ++--- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/inference_endpoint/async_utils/services/launcher.py b/src/inference_endpoint/async_utils/services/launcher.py index 8cce48666..b71cb1746 100644 --- a/src/inference_endpoint/async_utils/services/launcher.py +++ b/src/inference_endpoint/async_utils/services/launcher.py @@ -147,7 +147,7 @@ async def launch( # re-raise the exception. raise - def terminate(self, module: str) -> None: + def terminate_module(self, module: str) -> None: """SIGTERM managed subprocesses whose module exactly matches ``module``. Targeted so the whole-run watchdog can abort the metrics aggregator diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index ba46155dd..a52d1f14b 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -891,20 +891,17 @@ def _on_phase_start(phase: PhaseConfig) -> None: loop.add_signal_handler(signal.SIGINT, session.stop) try: - if watchdog.fired: - # Deadline elapsed during setup — never start issuing - # load after it. Run the already-stopped session so - # STARTED/ENDED still flow: the event logger exits only - # on ENDED, and the drain below waits for it. Zero - # samples issue; the INTERRUPTED artifacts still get - # written. - session.stop() - result = await session.run(phases) - else: - result = await session.run( - phases, on_phase_start=_on_phase_start - ) - session_completed_normally = True + # A pre-session fire already stopped the session inside + # bind_session: zero samples issue, STARTED/ENDED still + # flow (the event logger exits only on ENDED), and the + # INTERRUPTED artifacts get written. It also never arms + # the profiler/perf-cap hook. + fired_before_run = watchdog.fired + result = await session.run( + phases, + on_phase_start=None if fired_before_run else _on_phase_start, + ) + session_completed_normally = not fired_before_run except Exception as e: if watchdog.fired: # The watchdog already aborted the run; a teardown race diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index 756331f88..ffb566d2e 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -372,7 +372,7 @@ def terminate_metrics_aggregator(self) -> None: """ if self._launcher is None: return - self._launcher.terminate(_AGGREGATOR_MODULE) + self._launcher.terminate_module(_AGGREGATOR_MODULE) def _kill_services(self) -> None: """Best-effort service termination owned by the pipeline ExitStack. diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index a5fcd65e5..9b2f7ecc8 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -111,8 +111,16 @@ def bind_task(self, task: asyncio.Task | None) -> None: self._task = task def bind_session(self, session: BenchmarkSession) -> None: - """Late-bind the session: it is created after the timer is armed.""" + """Late-bind the session: it is created after the timer is armed. + + A deadline that already fired stops the session immediately, so no + load is ever issued past it — the caller still runs the stopped + session so STARTED/ENDED flow (the event logger exits only on ENDED) + and the INTERRUPTED artifacts get written. + """ self._session = session + if self.fired: + session.stop() def _fire(self) -> None: self.fired = True diff --git a/tests/unit/async_utils/services/test_launcher.py b/tests/unit/async_utils/services/test_launcher.py index 15f5c78fb..2a1f3038f 100644 --- a/tests/unit/async_utils/services/test_launcher.py +++ b/tests/unit/async_utils/services/test_launcher.py @@ -20,10 +20,10 @@ def test_terminate_sigterms_only_exact_module_match(): launcher._procs = [target, bystander] launcher._modules = ["svc.metrics_aggregator", "prefix.svc.metrics_aggregator"] try: - launcher.terminate("svc.metrics_aggregator") + launcher.terminate_module("svc.metrics_aggregator") assert target.wait(timeout=5.0) == -signal.SIGTERM assert bystander.poll() is None, ( - "terminate() must match the exact module name; a mere suffix match " + "terminate_module() must match the exact module name; a mere suffix match " "must stay alive" ) finally: @@ -41,6 +41,6 @@ def test_terminate_ignores_already_exited_proc(): launcher._procs = [dead] launcher._modules = ["svc.metrics_aggregator"] - launcher.terminate("svc.metrics_aggregator") + launcher.terminate_module("svc.metrics_aggregator") assert dead.returncode == 0 From 75c775d014f219b8a3f176f90adb18eae46298b2 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:37:45 -0700 Subject: [PATCH 16/45] feat(config): reinstate min_duration_ms as a workload duration on settings.runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review discussion questioned min_duration living in the timeouts block (it is a lower bound on the workload, not a give-up deadline) — the first cut of this branch resolved that by deleting the knob outright, which lost a real feature: --duration (min_duration_ms) was forwarded into RuntimeSettings on main and sized the run as target_qps × duration samples. The intended replacement (a ruleset setting it via apply_user_config/UserConfig) is not wired yet, so the knob was simply unreachable. Reinstated where the reviewer said bounds belong: beside max_duration_ms on settings.runtime, suffix parsing (600s/10m) and the max >= min cross-check restored, under the branch's conventions — int | None with None = no duration target (issue the dataset once, the default; main defaulted to 600000 with 0 as the sentinel). from_config forwards it again; unit test covers suffix parse + qps × duration derivation + the dataset-once default. --- docs/CLI_QUICK_REFERENCE.md | 1 + docs/config/DESIGN.md | 22 +++++----- .../config/runtime_settings.py | 2 +- src/inference_endpoint/config/schema.py | 41 +++++++++++++++++-- .../templates/concurrency_template_full.yaml | 1 + .../templates/offline_template_full.yaml | 1 + .../templates/online_template_full.yaml | 1 + tests/unit/config/test_schema.py | 31 ++++++++++++++ tests/unit/config/test_timeouts.py | 8 ---- 9 files changed, 84 insertions(+), 24 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index c5f56208c..f64fffd1d 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -161,6 +161,7 @@ run_benchmark ── run_timeout_s deadline captured here ─────── | YAML path | CLI flag | Semantics | | ----------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.min_duration_ms` | `--duration` | Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`); None = issue the dataset once | | `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | | `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | | `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index 60458ce00..47ad106d9 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -58,17 +58,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | -------------- | ------------------------------------------------------------------------------------------------ | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int \| None` | explicit, else dataset size; uses `target_qps` × `min_duration_ms` (padded) if a ruleset applies | -| `min_duration_ms` | `int \| None` | set only when a ruleset is applied (`UserConfig`) | -| `max_duration_ms` | `int \| None` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric` | primary target driving scheduler logic | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | +| `min_duration_ms` | `int \| None` | `--duration` / `runtime.min_duration_ms` (None = no duration target); a ruleset may override once ruleset integration lands | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric` | primary target driving scheduler logic | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/src/inference_endpoint/config/runtime_settings.py b/src/inference_endpoint/config/runtime_settings.py index 3bd6e15bc..36cd4825a 100644 --- a/src/inference_endpoint/config/runtime_settings.py +++ b/src/inference_endpoint/config/runtime_settings.py @@ -190,7 +190,7 @@ def _from_config_default( kwargs = { "metric_target": metrics.Throughput(effective_qps), "reported_metrics": [metrics.Throughput(effective_qps)], - "min_duration_ms": None, + "min_duration_ms": runtime_cfg.min_duration_ms, "max_duration_ms": runtime_cfg.max_duration_ms, "n_samples_from_dataset": dataloader_num_samples, "n_samples_to_issue": runtime_cfg.n_samples_to_issue, # From config (CLI --num-samples or YAML) diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 9fc6f2687..ef02a20bf 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -597,21 +597,41 @@ class RuntimeConfig(BaseModel): Sample count priority (in RuntimeSettings.total_samples_to_issue()): 1. n_samples_to_issue (if specified) — explicit override - 2. All dataset samples — issue the dataset once + 2. Calculated from target_qps × min_duration_ms — when a min duration is set + 3. All dataset samples — issue the dataset once (the default) - ``max_duration_ms`` is a workload duration (part of the benchmark - definition), not a give-up deadline — those live in ``settings.timeouts``. + ``min_duration_ms``/``max_duration_ms`` are workload durations (part of the + benchmark definition), not give-up deadlines — those live in + ``settings.timeouts``. """ model_config = ConfigDict(extra="forbid", frozen=True) + min_duration_ms: Annotated[ + int | None, + cyclopts.Parameter( + alias="--duration", + help=( + "Size the run by time instead of sample count: issue " + "target_qps × duration samples (ms, or with suffix: 600s, 10m; " + "None = issue the dataset once)" + ), + ), + ] = Field( + None, + gt=0, + description=( + "Minimum test duration in ms; sizes the run as target_qps × " + "duration samples (None = no duration target, issue the dataset once)" + ), + ) max_duration_ms: int | None = Field( None, gt=0, description="Maximum test duration in ms (None for no limit)", ) - @field_validator("max_duration_ms", mode="before") + @field_validator("min_duration_ms", "max_duration_ms", mode="before") @classmethod def _parse_duration_suffix(cls, v: object) -> object: """Accept duration with unit suffix: 600s, 10m, 600000ms, or plain int (ms).""" @@ -632,6 +652,19 @@ def _parse_duration_suffix(cls, v: object) -> object: scheduler_random_seed: int = Field(42, description="Scheduler RNG seed") dataloader_random_seed: int = Field(42, description="Dataloader RNG seed") + @model_validator(mode="after") + def _validate_durations(self) -> Self: + if ( + self.max_duration_ms is not None + and self.min_duration_ms is not None + and self.max_duration_ms < self.min_duration_ms + ): + raise ValueError( + f"max_duration_ms ({self.max_duration_ms}) must be >= " + f"min_duration_ms ({self.min_duration_ms})" + ) + return self + @cyclopts.Parameter(name="*") class LoadPattern(BaseModel): diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 9e37525f3..29d21c708 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -51,6 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: + min_duration_ms: null # Minimum test duration in ms; sizes the run as target_qps × duration samples (None = no duration target, issue the dataset once) max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index be2d86c9e..bff241eb9 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -51,6 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: + min_duration_ms: null # Minimum test duration in ms; sizes the run as target_qps × duration samples (None = no duration target, issue the dataset once) max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 4c6f4e506..9a9872942 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -51,6 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: + min_duration_ms: null # Minimum test duration in ms; sizes the run as target_qps × duration samples (None = no duration target, issue the dataset once) max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py index 949670e59..6f59245fc 100644 --- a/tests/unit/config/test_schema.py +++ b/tests/unit/config/test_schema.py @@ -37,6 +37,7 @@ OSLDistributionType, ProfilerEngine, ProfilingConfig, + RuntimeConfig, StreamingMode, SubmissionReference, TestType, @@ -524,6 +525,36 @@ def test_max_duration_defaults_to_none_in_runtime_settings(self): rt = RuntimeSettings.from_config(config, dataloader_num_samples=100) assert rt.max_duration_ms is None + @pytest.mark.unit + def test_min_duration_sizes_the_run(self): + """--duration (runtime.min_duration_ms) drives target_qps × duration + sample-count derivation, with suffix parsing; None = dataset once.""" + from inference_endpoint.config.runtime_settings import RuntimeSettings + + config = BenchmarkConfig( + type=TestType.ONLINE, + model_params={"name": "M"}, + endpoint_config={"endpoints": ["http://x"]}, + datasets=[{"path": "D"}], + settings={ + "load_pattern": {"type": "poisson", "target_qps": 10}, + "runtime": {"min_duration_ms": "600s"}, + }, + ) + assert config.settings.runtime.min_duration_ms == 600_000 + rt = RuntimeSettings.from_config(config, dataloader_num_samples=100) + # 10 QPS × 600 s × 1.1 padding = 6600, ceil'd past the float artifact + # (6600.0000…01 → 6601) then padded up to the next dataset multiple. + assert rt.total_samples_to_issue() == 6700 + + no_duration = config.with_updates( + settings=config.settings.model_copy( + update={"runtime": RuntimeConfig(min_duration_ms=None)} + ) + ) + rt = RuntimeSettings.from_config(no_duration, dataloader_num_samples=100) + assert rt.total_samples_to_issue() == 100 + @pytest.mark.unit def test_from_yaml_file_not_found(self): from pathlib import Path diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index 170c00cc4..ca155d8bd 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -144,14 +144,6 @@ def test_settings_service_ready_timeout_rejected(self): settings={"service_ready_timeout_s": 10.0}, ) - @pytest.mark.unit - def test_runtime_min_duration_rejected(self): - with pytest.raises(ValidationError, match="min_duration_ms"): - BenchmarkConfig( - **_MINIMAL_KWARGS, - settings={"runtime": {"min_duration_ms": 1000}}, - ) - @pytest.mark.unit def test_top_level_timeout_rejected(self): with pytest.raises(ValidationError, match="timeout"): From 7d6e3a6d09062b31e9afa55a0fb94dda90d5d739 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:41:59 -0700 Subject: [PATCH 17/45] docs(runtime): replace stale Phase-3/4 plan references with current state The phase numbering came from the 2025Q4 project plan, which is closed; the comments read as if ruleset apply_user_config wiring is scheduled when it has no active tracking issue. State the actual contract: the ruleset arg is accepted but unapplied, rulesets enforce schema-side constraints only. --- .../config/runtime_settings.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/inference_endpoint/config/runtime_settings.py b/src/inference_endpoint/config/runtime_settings.py index 36cd4825a..97afb7633 100644 --- a/src/inference_endpoint/config/runtime_settings.py +++ b/src/inference_endpoint/config/runtime_settings.py @@ -82,7 +82,7 @@ class RuntimeSettings: and ruleset constraints. It should never be instantiated directly by users, but rather created through: - Ruleset.apply_user_config() for ruleset-constrained configs - - RuntimeSettings.from_config() factory method (to be added in Phase 3) + - RuntimeSettings.from_config() factory method All fields are immutable (frozen dataclass) to prevent accidental modification during benchmark execution. @@ -145,15 +145,13 @@ def from_config( Returns: Immutable RuntimeSettings instance - Note: If a ruleset is provided, it would handle the conversion with competition-specific logic. - For now, we use default conversion. Full ruleset integration is deferred to Phase 4. + Note: the ``ruleset`` argument is accepted but NOT applied — wiring + ``BenchmarkSuiteRuleset.apply_user_config()`` (which would need a + ``UserConfig`` the CLI flow doesn't build yet) into this factory is + future work with no active tracking issue; until then every config + gets the default conversion below (rulesets still enforce their + schema-side constraints, e.g. pinned RNG seeds). """ - if ruleset is not None: - # Ruleset handles conversion with competition-specific logic - # This would need UserConfig which we don't have in the current CLI flow - # For now, we use default conversion even if ruleset is provided - # Full ruleset integration is deferred to Phase 4 - pass return cls._from_config_default(config, dataloader_num_samples, **overrides) From c0159f44a73dacce639308a321c7863bd1cb6cec Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Tue, 18 Aug 2026 17:59:32 -0700 Subject: [PATCH 18/45] fix(config): min_duration_ms requires an explicit poisson target_qps; drop the synthetic 10.0 QPS default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duration sizing multiplies a rate by a time — outside poisson there is no rate. Previously offline/max_throughput fell back to a hardcoded effective_qps of 10.0 (a self-described temporary compat default), so --duration on an offline run silently sized it as 10 QPS × duration, unrelated to what the server sustains. - Settings validator rejects runtime.min_duration_ms unless the load pattern is poisson with an explicit target_qps. - effective_qps is gone: RuntimeSettings.metric_target is now Metric | None (None when no target_qps), reported_metrics [] — the ruleset path already produced exactly these shapes. - total_samples_to_issue guards the programmatic path with a clear error instead of a nonsense count. --- docs/CLI_QUICK_REFERENCE.md | 2 +- docs/config/DESIGN.md | 22 +++++++-------- .../config/runtime_settings.py | 27 ++++++++++++------- src/inference_endpoint/config/schema.py | 21 ++++++++++++++- tests/unit/config/test_schema.py | 25 +++++++++++++++++ 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index f64fffd1d..803f1cce5 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -161,7 +161,7 @@ run_benchmark ── run_timeout_s deadline captured here ─────── | YAML path | CLI flag | Semantics | | ----------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.min_duration_ms` | `--duration` | Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`); None = issue the dataset once | +| `settings.runtime.min_duration_ms` | `--duration` | Sizes the run by time: issue `target_qps` × duration samples (poisson only — requires explicit `target_qps`; ms, or suffix: `600s`, `10m`); None = issue the dataset once | | `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | | `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | | `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index 47ad106d9..6e9639abd 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -58,17 +58,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | -| `min_duration_ms` | `int \| None` | `--duration` / `runtime.min_duration_ms` (None = no duration target); a ruleset may override once ruleset integration lands | -| `max_duration_ms` | `int \| None` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric` | primary target driving scheduler logic | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | +| `min_duration_ms` | `int \| None` | `--duration` / `runtime.min_duration_ms` (None = no duration target); a ruleset may override once ruleset integration lands | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/src/inference_endpoint/config/runtime_settings.py b/src/inference_endpoint/config/runtime_settings.py index 97afb7633..0e07a868f 100644 --- a/src/inference_endpoint/config/runtime_settings.py +++ b/src/inference_endpoint/config/runtime_settings.py @@ -88,7 +88,7 @@ class RuntimeSettings: during benchmark execution. """ - metric_target: metrics.Metric + metric_target: metrics.Metric | None """Primary metric to target (e.g., Throughput(100) for 100 QPS)""" reported_metrics: list[metrics.Metric] @@ -176,18 +176,19 @@ def _from_config_default( runtime_cfg = config.settings.runtime load_pattern_cfg = config.settings.load_pattern - # TODO: The default target_qps should be None in Offline mode, but we use 10.0 for now. - # This is a temporary solution to avoid breaking changes. - effective_qps = ( - load_pattern_cfg.target_qps - if load_pattern_cfg.target_qps is not None - else 10.0 - ) + # No synthetic default: patterns without an explicit target_qps carry + # no throughput target (min_duration_ms sizing requires one — enforced + # at the schema layer). + target_qps = load_pattern_cfg.target_qps # Build kwargs from Pydantic models kwargs = { - "metric_target": metrics.Throughput(effective_qps), - "reported_metrics": [metrics.Throughput(effective_qps)], + "metric_target": ( + metrics.Throughput(target_qps) if target_qps is not None else None + ), + "reported_metrics": ( + [metrics.Throughput(target_qps)] if target_qps is not None else [] + ), "min_duration_ms": runtime_cfg.min_duration_ms, "max_duration_ms": runtime_cfg.max_duration_ms, "n_samples_from_dataset": dataloader_num_samples, @@ -259,6 +260,12 @@ def total_samples_to_issue( return result # Calculate from duration and metric target + if self.metric_target is None: + # Schema validation rejects min_duration_ms without a poisson + # target_qps; guard the programmatic path too. + raise ValueError( + "min_duration_ms requires an explicit load_pattern.target_qps" + ) if isinstance(self.metric_target, metrics.Throughput): expected_sps = self.metric_target.target expected_samples = expected_sps * (self.min_duration_ms / 1000) diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index ef02a20bf..88f0645cd 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -614,7 +614,8 @@ class RuntimeConfig(BaseModel): help=( "Size the run by time instead of sample count: issue " "target_qps × duration samples (ms, or with suffix: 600s, 10m; " - "None = issue the dataset once)" + "None = issue the dataset once). Poisson only — requires an " + "explicit target_qps" ), ), ] = Field( @@ -1026,6 +1027,24 @@ class Settings(WithUpdatesMixin, BaseModel): ), ) + @model_validator(mode="after") + def _min_duration_requires_qps(self) -> Self: + # min_duration_ms sizes the run as target_qps × duration, so it is + # only meaningful when an explicit issue rate exists: poisson with + # target_qps. Offline bursts and fixed-concurrency runs have no rate + # to multiply by — no synthetic default is invented. + if self.runtime.min_duration_ms is not None and ( + self.load_pattern.type != LoadPatternType.POISSON + or self.load_pattern.target_qps is None + ): + raise ValueError( + "runtime.min_duration_ms (--duration) requires a poisson load " + "pattern with an explicit target_qps; offline/max_throughput " + "and concurrency runs are sized by --num-samples or the " + "dataset size" + ) + return self + class OfflineSettings(Settings): """Offline mode default settings.""" diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py index 6f59245fc..bff1f061c 100644 --- a/tests/unit/config/test_schema.py +++ b/tests/unit/config/test_schema.py @@ -555,6 +555,31 @@ def test_min_duration_sizes_the_run(self): rt = RuntimeSettings.from_config(no_duration, dataloader_num_samples=100) assert rt.total_samples_to_issue() == 100 + @pytest.mark.unit + @pytest.mark.parametrize( + "load_pattern", + [ + {"type": "max_throughput"}, + {"type": "concurrency", "target_concurrency": 8}, + ], + ) + def test_min_duration_requires_poisson_target_qps(self, load_pattern): + """Duration sizing has no rate to multiply by outside poisson — + rejected instead of inventing a synthetic default QPS.""" + with pytest.raises(ValidationError, match="min_duration_ms .* requires"): + BenchmarkConfig( + type=TestType.ONLINE + if load_pattern["type"] == "concurrency" + else TestType.OFFLINE, + model_params={"name": "M"}, + endpoint_config={"endpoints": ["http://x"]}, + datasets=[{"path": "D"}], + settings={ + "load_pattern": load_pattern, + "runtime": {"min_duration_ms": "600s"}, + }, + ) + @pytest.mark.unit def test_from_yaml_file_not_found(self): from pathlib import Path From b8042a7a0f92349d15374b8e9e5490ecb2cdb14d Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 12:46:01 -0700 Subject: [PATCH 19/45] fix(execute): a stopped session is not a normal completion; review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.run returns normally even when stop() aborted it mid-run (Ctrl-C via the SIGINT handler, transport closure, watchdog) — the completed-normally flag keyed only on the pre-run watchdog state, so an aborted session could still promote a drain failure to a hard error and mark the profiler run successful. BenchmarkSession now exposes a stop_requested property (the per-phase max_duration cap deliberately does not set it — reaching the cap is a normal phase end) and execute.py checks it after run returns. Also: rewrap the snapshot.py SessionState docstring line, and state the --timeout-overrides-YAML rule explicitly in CLI_DESIGN.md. --- docs/CLI_DESIGN.md | 2 ++ .../services/metrics_aggregator/snapshot.py | 4 +++- src/inference_endpoint/commands/benchmark/execute.py | 7 ++++++- src/inference_endpoint/load_generator/session.py | 10 ++++++++++ tests/unit/commands/test_benchmark.py | 1 + 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/CLI_DESIGN.md b/docs/CLI_DESIGN.md index 9b9807a1f..c96f9f75c 100644 --- a/docs/CLI_DESIGN.md +++ b/docs/CLI_DESIGN.md @@ -69,6 +69,8 @@ Both paths produce the **same subclass with the same defaults**. A YAML file wit 3. **Optional CLI overrides.** `--timeout` maps into `settings.timeouts.run_timeout_s` via `with_updates(...)` (re-runs validators); `--mode` selects the `TestMode` passed to the runner and never touches the config object. +> CLI `--timeout` overrides YAML `settings.timeouts.run_timeout_s`. + ### Why subclasses? `OfflineBenchmarkConfig` and `OnlineBenchmarkConfig` exist in the schema (not just CLI) so both paths share them: diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py index af24e7f88..59d2e1992 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py @@ -45,7 +45,9 @@ class SessionState(str, Enum): LIVE → run in progress; tick task publishing live HDR-derived stats. DRAINING → ``SessionEventType.ENDED`` has been received; the aggregator is tokenizing the buffered samples (bounded by the - ``--drain-timeout`` budget / ``settings.timeouts.metrics_drain_timeout_s``: None or omitted = unlimited, 0 = give up immediately). Tick task + ``--drain-timeout`` budget, i.e. + ``settings.timeouts.metrics_drain_timeout_s``: None or an + omitted flag = unlimited, 0 = give up immediately). Tick task continues at this stage, still HDR-derived; no new events will arrive. COMPLETE → terminal clean state. The ``publish_final()`` snapshot diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index a52d1f14b..0728306d6 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -901,7 +901,12 @@ def _on_phase_start(phase: PhaseConfig) -> None: phases, on_phase_start=None if fired_before_run else _on_phase_start, ) - session_completed_normally = not fired_before_run + # session.run returns normally even when stop() aborted it + # mid-run (Ctrl-C, transport closure, watchdog) — check the + # session's own flag, not just the pre-run watchdog state. + session_completed_normally = not ( + fired_before_run or session.stop_requested + ) except Exception as e: if watchdog.fired: # The watchdog already aborted the run; a teardown race diff --git a/src/inference_endpoint/load_generator/session.py b/src/inference_endpoint/load_generator/session.py index 3aa98d64f..371beb019 100644 --- a/src/inference_endpoint/load_generator/session.py +++ b/src/inference_endpoint/load_generator/session.py @@ -392,6 +392,16 @@ def stop(self) -> None: if self._strategy_task and not self._strategy_task.done(): self._strategy_task.cancel() + @property + def stop_requested(self) -> bool: + """True once stop() ran — Ctrl-C, transport closure, or watchdog. + + Distinguishes a session whose run() returned after an abort from one + that completed normally; the per-phase cap (stop_current_phase) does + NOT set it, since reaching max_duration_ms is a normal phase end. + """ + return self._stop_requested + def stop_current_phase(self) -> None: """End the in-progress phase without aborting the session. diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 061738269..90c8305da 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -1432,6 +1432,7 @@ async def _launch_ok(service_configs, *, timeout): mock_client.shutdown_async = AsyncMock() mock_session = MagicMock() mock_session.run = AsyncMock(return_value=MagicMock()) # clean success + mock_session.stop_requested = False # nothing aborted this session loop = asyncio.get_event_loop() with ( From 44b80a04febad99ddc8e327e3777db7251722b8d Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 13:58:16 -0700 Subject: [PATCH 20/45] chore(deps): bump datasets to 5.0.1 (PYSEC-2026-3716) --- pyproject.toml | 2 +- uv.lock | 1091 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1070 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc3d7b6f2..1993213c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ dependencies = [ # Required by transformers' apply_chat_template "jinja2==3.1.6", "numpy>=1.26.4", - "datasets==4.8.4", + "datasets==5.0.1", "Pillow==12.3.0", "sentencepiece==0.2.1", "protobuf==7.34.1", diff --git a/uv.lock b/uv.lock index 188b30b07..e8d8d6dcc 100644 --- a/uv.lock +++ b/uv.lock @@ -2,34 +2,82 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '3.14' and python_full_version < '4' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '4' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", - "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '3.14' and python_full_version < '4' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '4' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", - "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '3.14' and python_full_version < '4' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '4' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", - "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '3.14' and python_full_version < '4' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", "python_full_version >= '4' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", - "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", - "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version >= '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra == 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra == 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", + "python_full_version < '3.14' and platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl' and extra != 'extra-18-inference-endpoint-dev' and extra != 'extra-18-inference-endpoint-performance' and extra != 'extra-18-inference-endpoint-test'", ] supported-markers = [ "platform_machine == 'x86_64' and sys_platform == 'linux'", @@ -83,9 +131,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, @@ -93,9 +154,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, @@ -103,16 +177,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -288,12 +384,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] @@ -372,28 +474,50 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8 wheels = [ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] @@ -413,24 +537,68 @@ sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c764272 wheels = [ { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -484,33 +652,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] [[package]] @@ -521,34 +714,79 @@ sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb wheels = [ { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] @@ -565,29 +803,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] [[package]] @@ -600,14 +850,19 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c2/65bfd79292b8ff18be4dd7f7442cea37bcbc1a228c1886f1dea515c45b67/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:694ba35023846625ef471257e6b5a4bc8af690f961d197d77d34b1d1db393f56", size = 11760260, upload-time = "2025-10-21T14:51:40.79Z" }, { url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/df/6b/9c1b1a6c01392bfdd758e9486f52a1a72bc8f49e98f9355774ef98b5fb4e/cuda_bindings-12.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:696ca75d249ddf287d01b9a698b8e2d8a05046495a9c051ca15659dc52d17615", size = 11586961, upload-time = "2025-10-21T14:51:45.394Z" }, { url = "https://files.pythonhosted.org/packages/05/8b/b4b2d1c7775fa403b64333e720cfcfccef8dcb9cdeb99947061ca5a77628/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8bfaedc238f3b115d957d1fd6562b7e8435ba57f6d0e2f87d0e7149ccb2da5", size = 11570071, upload-time = "2025-10-21T14:51:47.472Z" }, { url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/05/d0/d0e4e2e047d8e899f023fa15ad5e9894ce951253f4c894f1cd68490fdb14/cuda_bindings-12.9.4-cp313-cp313-win_amd64.whl", hash = "sha256:a2e82c8985948f953c2be51df45c3fe11c812a928fca525154fb9503190b3e64", size = 11556719, upload-time = "2025-10-21T14:51:52.248Z" }, { url = "https://files.pythonhosted.org/packages/ec/07/6aff13bc1e977e35aaa6b22f52b172e2890c608c6db22438cf7ed2bf43a6/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3adf4958dcf68ae7801a59b73fb00a8b37f8d0595060d66ceae111b1002de38d", size = 11566797, upload-time = "2025-10-21T14:51:54.581Z" }, { url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3c/972edfddb4ae8a9fccd3c3766ed47453b6f805b6026b32f10209dd4b8ad4/cuda_bindings-12.9.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b32d8b685f0e66f5658bcf4601ef034e89fc2843582886f0a58784a4302da06c", size = 11894363, upload-time = "2025-10-21T14:51:58.633Z" }, { url = "https://files.pythonhosted.org/packages/1e/b5/96a6696e20c4ffd2b327f54c7d0fde2259bdb998d045c25d5dedbbe30290/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f53a7f453d4b2643d8663d036bafe29b5ba89eb904c133180f295df6dc151e5", size = 11624530, upload-time = "2025-10-21T14:52:01.539Z" }, { url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" }, + { url = "https://files.pythonhosted.org/packages/e6/87/652796522cc1a7af559460e1ce59b642e05c1468b9c08522a9a096b4cf04/cuda_bindings-12.9.4-cp314-cp314-win_amd64.whl", hash = "sha256:53a10c71fdbdb743e0268d07964e5a996dd00b4e43831cbfce9804515d97d575", size = 11517716, upload-time = "2025-10-21T14:52:06.013Z" }, { url = "https://files.pythonhosted.org/packages/39/73/d2fc40c043bac699c3880bf88d3cebe9d88410cd043795382826c93a89f0/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20f2699d61d724de3eb3f3369d57e2b245f93085cab44fd37c3bea036cea1a6f", size = 11565056, upload-time = "2025-10-21T14:52:08.338Z" }, { url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" }, + { url = "https://files.pythonhosted.org/packages/ab/52/a30f46e822bfa6b4a659d1e8de8c4a4adf908ea075dac568b55362541bd8/cuda_bindings-12.9.4-cp314-cp314t-win_amd64.whl", hash = "sha256:53e11991a92ff6f26a0c8a98554cd5d6721c308a6b7bfb08bebac9201e039e43", size = 12055608, upload-time = "2025-10-21T14:52:12.335Z" }, ] [[package]] @@ -697,7 +952,7 @@ wheels = [ [[package]] name = "datasets" -version = "4.8.4" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, @@ -718,9 +973,9 @@ dependencies = [ { name = "tqdm", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, { name = "xxhash", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/22/73e46ac7a8c25e7ef0b3bd6f10da3465021d90219a32eb0b4d2afea4c56e/datasets-4.8.4.tar.gz", hash = "sha256:a1429ed853275ce7943a01c6d2e25475b4501eb758934362106a280470df3a52", size = 604382, upload-time = "2026-03-23T14:21:17.987Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/5b/836516269d4f618efe621661cfb6f9acc57e6f95265db3efaee48a5ffe04/datasets-5.0.1.tar.gz", hash = "sha256:ce22bb851efd7494f08aad33b940803784434f6e77763d00679a0dc45fcf686a", size = 641498, upload-time = "2026-07-28T11:09:12.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/e5/247d094108e42ac26363ab8dc57f168840cf7c05774b40ffeb0d78868fcc/datasets-4.8.4-py3-none-any.whl", hash = "sha256:cdc8bee4698e549d78bf1fed6aea2eebc760b22b084f07e6fc020c6577a6ce6d", size = 526991, upload-time = "2026-03-23T14:21:15.89Z" }, + { url = "https://files.pythonhosted.org/packages/44/0b/98fc6eb83333508ca5f44c52b3e287ea8137a0ad582714e2cbc67a02154b/datasets-5.0.1-py3-none-any.whl", hash = "sha256:9fbf73688f8c18f7529b4fe592abd04015f81d1e58001e4bac73ffb2b39d7cc4", size = 559079, upload-time = "2026-07-28T11:09:10.266Z" }, ] [[package]] @@ -808,16 +1063,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/03/293bccd838a293d42ea26dec7f4eb4f58b57b6c9ffcfabc6518a5f20a24a/duckdb-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed6d23a3f806898e69c77430ebd8da0c79c219f97b9acbc9a29a653e09740c59", size = 14246803, upload-time = "2026-03-23T12:11:09.624Z" }, { url = "https://files.pythonhosted.org/packages/15/2c/7b4f11879aa2924838168b4640da999dccda1b4a033d43cb998fd6dc33ea/duckdb-1.5.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6af347debc8b721aa72e48671166282da979d5e5ae52dbc660ab417282b48e23", size = 19271654, upload-time = "2026-03-23T12:11:13.354Z" }, { url = "https://files.pythonhosted.org/packages/6f/d6/8f9a6b1fbcc669108ec6a4d625a70be9e480b437ed9b70cd56b78cd577a6/duckdb-1.5.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8150c569b2aa4573b51ba8475e814aa41fd53a3d510c1ffb96f1139f46faf611", size = 21386100, upload-time = "2026-03-23T12:11:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/8d02c6473273468cf8d43fd5d73c677f8cdfcd036c1e884df0613f124c2b/duckdb-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:054ad424b051b334052afac58cb216f3b1ebb8579fc8c641e60f0182e8725ea9", size = 13083506, upload-time = "2026-03-23T12:11:19.785Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/2be786b9c153eb263bf5d3d5f7ab621b14a715d7e70f92b24ecf8536369e/duckdb-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:6ba302115f63f6482c000ccfd62efdb6c41d9d182a5bcd4a90e7ab8cd13856eb", size = 13888862, upload-time = "2026-03-23T12:11:22.84Z" }, { url = "https://files.pythonhosted.org/packages/a5/f2/af476945e3b97417945b0f660b5efa661863547c0ea104251bb6387342b1/duckdb-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:26e56b5f0c96189e3288d83cf7b476e23615987902f801e5788dee15ee9f24a9", size = 30113759, upload-time = "2026-03-23T12:11:26.5Z" }, { url = "https://files.pythonhosted.org/packages/fe/9d/5a542b3933647369e601175190093597ce0ac54909aea0dd876ec51ffad4/duckdb-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:972d0dbf283508f9bc446ee09c3838cb7c7f114b5bdceee41753288c97fe2f7c", size = 15991463, upload-time = "2026-03-23T12:11:30.025Z" }, { url = "https://files.pythonhosted.org/packages/53/a5/b59cff67f5e0420b8f337ad86406801cffacae219deed83961dcceefda67/duckdb-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:482f8a13f2600f527e427f73c42b5aa75536f9892868068f0aaf573055a0135f", size = 14246482, upload-time = "2026-03-23T12:11:33.33Z" }, { url = "https://files.pythonhosted.org/packages/e9/12/d72a82fe502aae82b97b481bf909be8e22db5a403290799ad054b4f90eb4/duckdb-1.5.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da137802688190835b4c863cafa77fd7e29dff662ee6d905a9ffc14f00299c91", size = 19270816, upload-time = "2026-03-23T12:11:36.79Z" }, { url = "https://files.pythonhosted.org/packages/f9/c3/ee49319b15f139e04c067378f0e763f78336fbab38ba54b0852467dd9da4/duckdb-1.5.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d4147422d91ccdc2d2abf6ed24196025e020259d1d267970ae20c13c2ce84b1", size = 21385695, upload-time = "2026-03-23T12:11:40.465Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f5/a15498e75a27a136c791ca1889beade96d388dadf9811375db155fc96d1a/duckdb-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:05fc91767d0cfc4cf2fa68966ab5b479ac07561752e42dd0ae30327bd160f64a", size = 13084065, upload-time = "2026-03-23T12:11:43.763Z" }, + { url = "https://files.pythonhosted.org/packages/93/81/b3612d2bbe237f75791095e16767c61067ea5d31c76e8591c212dac13bd0/duckdb-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:a28531cee2a5a42d89f9ba4da53bfeb15681f12acc0263476c8705380dadce07", size = 13892892, upload-time = "2026-03-23T12:11:47.222Z" }, { url = "https://files.pythonhosted.org/packages/ad/75/e9e7893542ca738bcde2d41d459e3438950219c71c57ad28b049dc2ae616/duckdb-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:eba81e0b3011c1f23df7ea47ef4ffaa8239817959ae291515b6efd068bde2161", size = 30123677, upload-time = "2026-03-23T12:11:51.511Z" }, { url = "https://files.pythonhosted.org/packages/df/db/f7420ee7109a922124c02f377ae1c56156e9e4aa434f4726848adaef0219/duckdb-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:afab8b4b1f4469c3879bb049dd039f8fce402712050324e9524a43d7324c5e87", size = 15996808, upload-time = "2026-03-23T12:11:54.964Z" }, { url = "https://files.pythonhosted.org/packages/df/57/2c4c3de1f1110417592741863ba58b4eca2f7690a421712762ddbdcd72e6/duckdb-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:71dddcebbc5a70e946a06c30b59b5dd7999c9833d307168f90fb4e4b672ab63e", size = 14248990, upload-time = "2026-03-23T12:11:58.576Z" }, { url = "https://files.pythonhosted.org/packages/2b/81/e173b33ffac53124a3e39e97fb60a538f26651a0df6e393eb9bf7540126c/duckdb-1.5.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac2804043bd1bc10b5da18f8f4c706877197263a510c41be9b4c0062f5783dcc", size = 19276013, upload-time = "2026-03-23T12:12:02.034Z" }, { url = "https://files.pythonhosted.org/packages/d4/4c/47e838393aa90d3d78549c8c04cb09452efeb14aaae0ee24dc0bd61c3a41/duckdb-1.5.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8843bd9594e1387f1e601439e19ad73abdf57356104fd1e53a708255bb95a13d", size = 21387569, upload-time = "2026-03-23T12:12:05.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9b/ce65743e0e85f5c984d2f7e8a81bc908d0bac345d6d8b6316436b29430e7/duckdb-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:d68c5a01a283cb13b79eafe016fe5869aa11bff8c46e7141c70aa0aac808010f", size = 13603876, upload-time = "2026-03-23T12:12:09.344Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/f9e4e731635192571f86f52d86234f537c7f8ca4f6917c56b29051c077ef/duckdb-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:a3be2072315982e232bfe49c9d3db0a59ba67b2240a537ef42656cc772a887c7", size = 14370790, upload-time = "2026-03-23T12:12:12.497Z" }, ] [[package]] @@ -864,10 +1125,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/71/25f5f7b70a9f22a3efe19e7288278da460b043a3b60ad98e4e47401ed5aa/faiss_cpu-1.11.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c4a3d35993e614847f3221c6931529c0bac637a00eff0d55293e1db5cb98c85f", size = 7913537, upload-time = "2025-04-28T07:47:56.723Z" }, { url = "https://files.pythonhosted.org/packages/b0/c8/a5cb8466c981ad47750e1d5fda3d4223c82f9da947538749a582b3a2d35c/faiss_cpu-1.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8f9af33e0b8324e8199b93eb70ac4a951df02802a9dcff88e9afc183b11666f0", size = 3785180, upload-time = "2025-04-28T07:47:59.004Z" }, { url = "https://files.pythonhosted.org/packages/7f/37/eaf15a7d80e1aad74f56cf737b31b4547a1a664ad3c6e4cfaf90e82454a8/faiss_cpu-1.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:48b7e7876829e6bdf7333041800fa3c1753bb0c47e07662e3ef55aca86981430", size = 31287630, upload-time = "2025-04-28T07:48:01.248Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/902a78347e9c47baaf133e47863134e564c39f9afe105795b16ee986b0df/faiss_cpu-1.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:bdc199311266d2be9d299da52361cad981393327b2b8aa55af31a1b75eaaf522", size = 15005398, upload-time = "2025-04-28T07:48:04.232Z" }, { url = "https://files.pythonhosted.org/packages/92/90/d2329ce56423cc61f4c20ae6b4db001c6f88f28bf5a7ef7f8bbc246fd485/faiss_cpu-1.11.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:0c98e5feff83b87348e44eac4d578d6f201780dae6f27f08a11d55536a20b3a8", size = 3313807, upload-time = "2025-04-28T07:48:06.486Z" }, { url = "https://files.pythonhosted.org/packages/24/14/8af8f996d54e6097a86e6048b1a2c958c52dc985eb4f935027615079939e/faiss_cpu-1.11.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:796e90389427b1c1fb06abdb0427bb343b6350f80112a2e6090ac8f176ff7416", size = 7913539, upload-time = "2025-04-28T07:48:08.338Z" }, { url = "https://files.pythonhosted.org/packages/b2/2b/437c2f36c3aa3cffe041479fced1c76420d3e92e1f434f1da3be3e6f32b1/faiss_cpu-1.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b6e355dda72b3050991bc32031b558b8f83a2b3537a2b9e905a84f28585b47e", size = 3785181, upload-time = "2025-04-28T07:48:10.594Z" }, { url = "https://files.pythonhosted.org/packages/66/75/955527414371843f558234df66fa0b62c6e86e71e4022b1be9333ac6004c/faiss_cpu-1.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6c482d07194638c169b4422774366e7472877d09181ea86835e782e6304d4185", size = 31287635, upload-time = "2025-04-28T07:48:12.93Z" }, + { url = "https://files.pythonhosted.org/packages/50/51/35b7a3f47f7859363a367c344ae5d415ea9eda65db0a7d497c7ea2c0b576/faiss_cpu-1.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:13eac45299532b10e911bff1abbb19d1bf5211aa9e72afeade653c3f1e50e042", size = 15005455, upload-time = "2025-04-28T07:48:16.173Z" }, ] [[package]] @@ -881,11 +1144,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/8c/76ef4641e6c1c1aa3e6bb3c9efb5533ffda5dd975c8b5ae54e794322d9e3/fastavro-1.12.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25ef6855935f67582740ffa6bb978e40ec51be876117a3555c36fa2488dcdf25", size = 3425061, upload-time = "2026-04-24T14:36:35.497Z" }, { url = "https://files.pythonhosted.org/packages/31/10/379ff23425b2b470d5209cbc6736a6e5cbc34392ff17bb7355b8fd4aa0ca/fastavro-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84a4f76a0aece0aa72b5ed8162ba2ff8c78908b8361b5a5d92ddd161977ccb74", size = 3243618, upload-time = "2026-04-24T14:36:37.969Z" }, { url = "https://files.pythonhosted.org/packages/88/29/4c8f9e7cd78f932f0d82823899e67a6d7f7e8f2524992db03956f9d9f5ef/fastavro-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e8da77d201916f6771fc357fda8267c2a256d7aa11923d43bc5f2fc155878b", size = 3378427, upload-time = "2026-04-24T14:36:40.278Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/eafeb302aaaea6055d4a9c11272b4aeaf713e43fe8eaf782f43a1fee2b44/fastavro-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:1924349c74666c89417bd5cc2749f598e2f15f1d56ee81428b2317ab02c88aae", size = 441077, upload-time = "2026-04-24T14:36:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/56/9d/67e831041ba8efc16265c65bd71ba92e1095bba19b91be99e102f19d9be6/fastavro-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:4c346cf449baf3b113e997c34151ad205e7135bc429469b005b180ade7e65e28", size = 378205, upload-time = "2026-04-24T14:36:43.679Z" }, { url = "https://files.pythonhosted.org/packages/83/39/f489a441d41cc9c0a8449fb1325d7a9c9eb57a5634e6ab19dfb0a1105324/fastavro-1.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:57bb6b908cb2e05baab63b04c3a31be3b4545a10bfab9748b8763016b5256704", size = 958566, upload-time = "2026-04-24T14:36:45.49Z" }, { url = "https://files.pythonhosted.org/packages/31/69/776cc025aee2d02acacb734cf690d2fbc295eaadde1b5d47caf8c77a6a2b/fastavro-1.12.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a007f95cc682f56e6d83f1d17c29c00bf719d6fe8e003282b535af3a1ba09c0", size = 3276390, upload-time = "2026-04-24T14:36:47.875Z" }, { url = "https://files.pythonhosted.org/packages/8c/bc/b7e15fa788f42cbe65827af2ec06c9ad91bb9f72c213110dbef61b53a5b0/fastavro-1.12.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e90460b0cd21f62be3cb26087e706e2cebb7b3fcef9e05b4473b61bb0415b5e", size = 3372779, upload-time = "2026-04-24T14:36:50.122Z" }, { url = "https://files.pythonhosted.org/packages/79/c2/98993ca810231fc1397212f48c3d46626983722a24bbaaa5c27ee0963751/fastavro-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ccd15966b8218d41b06ec3e7c2556be89a8a693026c771e6564d2e40bbaf8ea", size = 3187591, upload-time = "2026-04-24T14:36:52.451Z" }, { url = "https://files.pythonhosted.org/packages/c6/bb/c180f340eba6478f1b20deccdd17e2b4a4d5074dafd812e3c4254fd035f7/fastavro-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06b6971d3dae10cb34353b857d16ad21ebd6f0ea394e86c96abdcad109005d6e", size = 3320589, upload-time = "2026-04-24T14:36:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e9/aca0456216b5b8992e7b0a8542711b66799c05bfe24c8e32ef6f56e7eb93/fastavro-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:98dfcdfaf1498ae2f0e2fafe900a82e8320cc81d8ae5a95b8b8879eaa3298c39", size = 440883, upload-time = "2026-04-24T14:36:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7e/984896e716af504927be71b80a1e9661aa96c6f9e1e777d52823aacb99f2/fastavro-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:3888ef7a51adc77cdf07251bc762566a1be36211e1cff689f13980f3776a2f36", size = 377536, upload-time = "2026-04-24T14:36:58.274Z" }, { url = "https://files.pythonhosted.org/packages/e9/42/09a1e1f8d9998d73848a6ff0aad6713ae6abf0dbf99918776f8ef33344a7/fastavro-1.12.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:283dcd3129b632021894425974bedd0eb6db3bbf5994e448ccad10db4d803d31", size = 1049506, upload-time = "2026-04-24T14:36:59.797Z" }, { url = "https://files.pythonhosted.org/packages/52/ef/80cc16f43919d532f25a707f34b275cccc09dca87a05b000fbbfc8e8f255/fastavro-1.12.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d125e210d5a0a1f701f12c0ecad9a03f1b04b5eddbce6ca36a1fc217da977ef", size = 3495899, upload-time = "2026-04-24T14:37:02.306Z" }, { url = "https://files.pythonhosted.org/packages/c1/54/a0817d1d0236e9e0233f5c996f450cc795b056b8e06edb531f24b9df82ed/fastavro-1.12.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4d66afad78e8f47feaa307728a6b71fe3effc63ba2b9eeb109ee687c9bd397", size = 3399232, upload-time = "2026-04-24T14:37:04.837Z" }, @@ -896,6 +1163,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/0b/b77be56c5109da0fc7dcfd7e6b6752fe0a61d0a5c58c6a65e38b4501946a/fastavro-1.12.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f604ba83498e209fff4c7ecc5063a39421dc538dace694bc592f9f338254f3dc", size = 3324020, upload-time = "2026-04-24T14:37:16.096Z" }, { url = "https://files.pythonhosted.org/packages/e7/6e/951d41f244107e91bf2f59245b71783c03eaab4bdbc960d58316c19652bb/fastavro-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bfac2dada8ddc002e8b7d8289d6fad4f070bc1fec20371cec684a7d10d932e96", size = 3170160, upload-time = "2026-04-24T14:37:18.168Z" }, { url = "https://files.pythonhosted.org/packages/94/6f/2adb571fda448d4afd2466e1cef2963fefdc6b37847da05249983e415f17/fastavro-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc44ba6289fb1f5ee318335958dde6ad6d742dcb4bb8930de843e9024c64b68c", size = 3281842, upload-time = "2026-04-24T14:37:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/17/07/4bad2e96c4c6bae40253be2573cc09c1e5b9ccf821e1ff74e0d33b64bf90/fastavro-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:a475418f71c5aed69899813ecccf392429c08c3a63df3030129db71760b0db8f", size = 450903, upload-time = "2026-04-24T14:37:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b7/180f67ba9a46ba23a1ff6432f48d3087d4f2048579ecc262b00426cb1c63/fastavro-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:daec9f9655a1d4636613c47d6d3343f6e039150d66cdce62543e20ca36612a8a", size = 391076, upload-time = "2026-04-24T14:37:24.756Z" }, { url = "https://files.pythonhosted.org/packages/dd/8f/18f60329b627d2118a4a2b19e8741fbd807d60bf0470554e1bbfb7f1bca3/fastavro-1.12.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:57594b72cf663bbd0f3ad8a319a999fc3d7c71065a6799b2c1d1a6a137894c5b", size = 1055430, upload-time = "2026-05-09T21:53:14.364Z" }, { url = "https://files.pythonhosted.org/packages/d2/ac/a1fa1fc29df0efc89d4946a743b09bdc9500591b5b92083eaf8e93664916/fastavro-1.12.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74412132bbfb153cbf704517f2c89f7d3e170feb681b13bceace690f66f8d5fa", size = 3503075, upload-time = "2026-04-24T14:37:26.826Z" }, { url = "https://files.pythonhosted.org/packages/82/bf/4f669e10b6bc38a731ee3400aed1a1e2d0a3e3cf411e72f6b320d3af0eaf/fastavro-1.12.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e367a84c9133018e0a3bc822abe78d7f1f9a6092991a0ec409468cf4ef260282", size = 3410900, upload-time = "2026-04-24T14:37:29.233Z" }, @@ -957,24 +1226,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] @@ -989,36 +1266,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] @@ -1101,24 +1423,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd9 wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] [[package]] @@ -1140,8 +1477,12 @@ dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/c2/79/674aad5279dd1a77b85efa1cbf8dcead209dc5f38f55cbbfd75bc20cc65b/hdrhistogram-0.10.3.tar.gz", hash = "sha256:f3890df0a6f3c582a0a8b2a49a568729cb319f1600683e4458cc98b68ca32841", size = 60077, upload-time = "2023-08-11T04:00:36.003Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/60/4d12ce18d95c815553751ace3936bccc54d67f47c7a2ebcd94c7fc89ca7f/hdrhistogram-0.10.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:088d3ef64c2004fc3cd4b21c4292efe4648367a1ce98c554bf7c5730a0ba018e", size = 36661, upload-time = "2023-08-11T03:59:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/d0/20/10edd9915fcad1bd87c062c5c049a536d9783ebadd4e7f606414bdb74ce5/hdrhistogram-0.10.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8ae7ab424e6f2221ae9daed20610becb5d59cae2d448a05077b00e864c9e7", size = 48686, upload-time = "2023-08-11T03:59:32.294Z" }, { url = "https://files.pythonhosted.org/packages/b1/8a/ca7b687c70409aec9a524e3ce7c044274f5108fd9c33cc93635237279b70/hdrhistogram-0.10.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2ba2550e8a392a543e727a4875f76f7131d1dd04ebe7c03d3cbe44b83fc130b", size = 47987, upload-time = "2023-08-11T03:59:33.84Z" }, + { url = "https://files.pythonhosted.org/packages/54/f5/1367cb6ef66d3d8c5e5091d8738d47a1f42414605b1638dd6785d23b9f99/hdrhistogram-0.10.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:57d61fd8378212d3d24149a331f770278766db541373d20a12f9399788ffde82", size = 53500, upload-time = "2023-08-11T03:59:35.132Z" }, { url = "https://files.pythonhosted.org/packages/a4/9d/c3ba5788f3feed8b2198a8a5461706f174912bb59595af616595a7cefd98/hdrhistogram-0.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ad6d3ca8bcec581b8cf936608f79f6dd619e2690d1135c1978d80b01318e19e3", size = 52533, upload-time = "2023-08-11T03:59:37.027Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/a41ade1c98bb4626f0ec95a5c56394b6e84b37e004338bd5b9cc24c61e29/hdrhistogram-0.10.3-cp312-cp312-win32.whl", hash = "sha256:90bf599703cd146b430fd4c111fb1290da902746ddea9c591c1cfb8313d37974", size = 39607, upload-time = "2023-08-11T03:59:38.166Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/f0e073f6ddabd71270135be8d1f5e7243e7c030f7468ef832d21c59eac54/hdrhistogram-0.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:92f0a43d0918ee6c48c78097c6e51eced260d0ae459c00a8a3690fbd9a06dc78", size = 40070, upload-time = "2023-08-11T03:59:39.167Z" }, ] [[package]] @@ -1156,18 +1497,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, + { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, ] [[package]] @@ -1204,18 +1551,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] [[package]] @@ -1386,7 +1736,7 @@ requires-dist = [ { name = "colorama", specifier = "==0.4.6" }, { name = "coverage", marker = "extra == 'test'", specifier = "==7.13.4" }, { name = "cyclopts", specifier = "==4.10.0" }, - { name = "datasets", specifier = "==4.8.4" }, + { name = "datasets", specifier = "==5.0.1" }, { name = "duckdb", specifier = "==1.5.1" }, { name = "filelock", marker = "extra == 'dev'", specifier = ">=3.20.3" }, { name = "hdrhistogram", specifier = "==0.10.3" }, @@ -1489,29 +1839,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, @@ -1595,39 +1979,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] [[package]] @@ -1658,6 +2082,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/6c/2d0286f67e6bb2b00ae23f9af6df18bfc6bb1ac5d803a8f46bd3eb22a8f1/line_profiler-5.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a870b68af1539d718d030f4c4726d35cff4b14ab605147e65222933c5c0e10e", size = 1484331, upload-time = "2026-02-23T23:30:20.571Z" }, { url = "https://files.pythonhosted.org/packages/4e/a4/b01359733214a1a85c5f86f3953b07deb61b267efa0328e8d436a1ad80ea/line_profiler-5.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fe8cd787caa2a02ca7e138832fa4cab1f198377eaf6e5e8263e8b7506157c454", size = 2411802, upload-time = "2026-02-23T23:30:21.995Z" }, { url = "https://files.pythonhosted.org/packages/d1/f4/1fa91206a6c50091cf614fdd5c9d349eb3a57d23f5eb8be8fffe7e0525b9/line_profiler-5.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:70ff915ade9e3ec38ff043ff093b590bbb3055e6fc8b311e0fe14cd78fb2a7f7", size = 2495790, upload-time = "2026-02-23T23:30:23.448Z" }, + { url = "https://files.pythonhosted.org/packages/87/18/d389c72dce6c8318c088a7c29ee8961a913c8a1c6469888b517e8f47ddaf/line_profiler-5.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:026779b9dfca0f367174f5d34bcccffce2755db40a4389f0d8a531a2e3ca7cfc", size = 478790, upload-time = "2026-02-23T23:30:24.848Z" }, + { url = "https://files.pythonhosted.org/packages/3f/54/d171600a4190c07215090a88846ef0093b5bf34a81f8059115592dbb1354/line_profiler-5.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:fe22b927f05a61a0149976bf0d22d8e56fa742ec89f3d72358db71a1f440c77b", size = 462269, upload-time = "2026-02-23T23:30:26.237Z" }, { url = "https://files.pythonhosted.org/packages/a7/64/856b920e026fbd239df875ec05e63583f7bd7f250805215ab6e132da11d1/line_profiler-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:016effba91d34d15229d41984e921a27f66a7b634f1d7adf6c57c743f3d6a0eb", size = 642642, upload-time = "2026-02-23T23:30:27.63Z" }, { url = "https://files.pythonhosted.org/packages/3b/08/0a56fab0a36818af6ffc8073700db2f402db5a62477b69d938c19871d631/line_profiler-5.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:506e800dd408a8aafadf39ff4e4a1375ae7794910d00098f191520a2f390cb99", size = 503787, upload-time = "2026-02-23T23:30:29.226Z" }, { url = "https://files.pythonhosted.org/packages/ed/9a/0ab45cf92b2c13261b475c440e18bb18d9497cc2ad5dfaf38c231c72b02b/line_profiler-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e67f77bcb349a663cb22819f65621bcd2a39889524dd890d1d88f8736841b7b", size = 493631, upload-time = "2026-02-23T23:30:30.502Z" }, @@ -1665,6 +2091,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/6f/0f399c72eecaf8f8c00e84238b5786afc34d0a4ef5ad10c63c712715ba86/line_profiler-5.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31290e06ac25cd87fee46ebe979541d4ec7c8d6f15c5cbe5874a932b1cee95bb", size = 1483425, upload-time = "2026-02-23T23:30:33.15Z" }, { url = "https://files.pythonhosted.org/packages/65/18/f4c642a29719a84d17ea8b58cd6e60943573a28228c30c568565ed5512aa/line_profiler-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1d7fbcc2dbd8534fc6f7d2b440076749b2235cdc525eb177fefafeaf7550373f", size = 2410276, upload-time = "2026-02-23T23:30:34.943Z" }, { url = "https://files.pythonhosted.org/packages/90/33/701203686e7d27a545e3bbc8e81fffc7d091c42ed33564be4e72376ef45b/line_profiler-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f04671f48afcd90858c18fbdb2509463c77d717ed5424664f096e902206b6b", size = 2495283, upload-time = "2026-02-23T23:30:36.616Z" }, + { url = "https://files.pythonhosted.org/packages/34/e1/59fe065f67ed1fb8f974a9e3434685af1fc1f6a154489f7ab0992eab1c73/line_profiler-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:d2262d4bbbcf72bd430fc5763073792a0f1cb20e64de0f7ecf6e8ae16627d876", size = 479287, upload-time = "2026-02-23T23:30:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/e9/83/89f6ae52fa77960404ee88fc078ee680e504bf1ab8724ac01430cee0f5a5/line_profiler-5.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:abf755b020d91b639cbc563015eca381ca64e6bd27ee55ef9004a3a17b6d4dcf", size = 461960, upload-time = "2026-02-23T23:30:39.657Z" }, { url = "https://files.pythonhosted.org/packages/c6/ae/43caf21edd10a7f5e138bdffcad01ade9a704462a923054402bbadbe5364/line_profiler-5.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a1cc30f3f7877fec826d0f40f400ee6c99239dc6a2f587b8d90d06a42d29c8a5", size = 648335, upload-time = "2026-02-23T23:30:41.042Z" }, { url = "https://files.pythonhosted.org/packages/34/90/8a1fb985dc582d140fc92608dec3037a484c5f8ab99ae05c24031aa68000/line_profiler-5.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f90923e1cc4ff8eda1d18e525089fca7bfd6dfe8817ec530a913a2c7444ba0fd", size = 508823, upload-time = "2026-02-23T23:30:42.16Z" }, { url = "https://files.pythonhosted.org/packages/a4/01/855c55e195ac0aadb8ca4e4c65311f945ed02a2491b436bc33cee318d841/line_profiler-5.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cc3d0ecccb14f014d05b32f687d22adcb98bf59fdcc721e7a4330f0372a56f92", size = 499868, upload-time = "2026-02-23T23:30:44.188Z" }, @@ -1672,6 +2100,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/1c/e1236e0f3c7ec1e19e74d61ac15143a7826b5767296de87bcf3aa26548a1/line_profiler-5.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cce501f9d996b317b599c0ae99e3eb1bd447874ef8fef1da330b27f3a23eb50", size = 1475222, upload-time = "2026-02-23T23:30:47.014Z" }, { url = "https://files.pythonhosted.org/packages/8e/ad/02302fd2a82949277036bc557ecebddb9bc6282b76a4da7660258fe82111/line_profiler-5.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b237d82fb792c3db7c80a8675d3c48993d4421b14d96ae602f7fe9ccf1f85903", size = 2413428, upload-time = "2026-02-23T23:30:48.828Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/b3efe646c8b9fdc6fe26720860276c8a2bb745ffe30f5bcbc9726b975673/line_profiler-5.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:74febeca89128a37a32e6500c99665943c0d11e6043f46ce95596d7d1e1732a7", size = 2494741, upload-time = "2026-02-23T23:30:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ad/ddadd39eb92900f063f27e8f6d748c03dc2638873f07ebf3cee75f29711f/line_profiler-5.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:d6ce98faff60d9552a30e233648a848682b5d664a7e09e9669163a8f01e28147", size = 485700, upload-time = "2026-02-23T23:30:52.373Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/a529f355eea8fb790fbdee0273d6c0049dba3232a36e82c30d849b00e996/line_profiler-5.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:8be7cc5f4ed9ad87352129d1a494cf5ba7f0fced0472201d83ac9fbfa20f798b", size = 469781, upload-time = "2026-02-23T23:30:53.747Z" }, ] [[package]] @@ -1696,32 +2126,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] @@ -1746,26 +2201,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, ] [[package]] @@ -1836,26 +2301,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -1870,24 +2355,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, { url = "https://files.pythonhosted.org/packages/8a/d1/b902d38b6e5ba3bdddbec469bba388d647f960aeed7b5b3623a8debe8a76/msgspec-0.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c1ff8db03be7598b50dd4b4a478d6fe93faae3bd54f4f17aa004d0e46c14c46", size = 196463, upload-time = "2025-11-24T03:55:43.405Z" }, { url = "https://files.pythonhosted.org/packages/57/b6/eff0305961a1d9447ec2b02f8c73c8946f22564d302a504185b730c9a761/msgspec-0.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f6532369ece217fd37c5ebcfd7e981f2615628c21121b7b2df9d3adcf2fd69b8", size = 188650, upload-time = "2025-11-24T03:55:44.761Z" }, { url = "https://files.pythonhosted.org/packages/99/93/f2ec1ae1de51d3fdee998a1ede6b2c089453a2ee82b5c1b361ed9095064a/msgspec-0.20.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9a1697da2f85a751ac3cc6a97fceb8e937fc670947183fb2268edaf4016d1ee", size = 218834, upload-time = "2025-11-24T03:55:46.441Z" }, { url = "https://files.pythonhosted.org/packages/28/83/36557b04cfdc317ed8a525c4993b23e43a8fbcddaddd78619112ca07138c/msgspec-0.20.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fac7e9c92eddcd24c19d9e5f6249760941485dff97802461ae7c995a2450111", size = 224917, upload-time = "2025-11-24T03:55:48.06Z" }, { url = "https://files.pythonhosted.org/packages/8f/56/362037a1ed5be0b88aced59272442c4b40065c659700f4b195a7f4d0ac88/msgspec-0.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f953a66f2a3eb8d5ea64768445e2bb301d97609db052628c3e1bcb7d87192a9f", size = 222821, upload-time = "2025-11-24T03:55:49.388Z" }, { url = "https://files.pythonhosted.org/packages/92/75/fa2370ec341cedf663731ab7042e177b3742645c5dd4f64dc96bd9f18a6b/msgspec-0.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:247af0313ae64a066d3aea7ba98840f6681ccbf5c90ba9c7d17f3e39dbba679c", size = 227227, upload-time = "2025-11-24T03:55:51.125Z" }, + { url = "https://files.pythonhosted.org/packages/f1/25/5e8080fe0117f799b1b68008dc29a65862077296b92550632de015128579/msgspec-0.20.0-cp313-cp313-win_amd64.whl", hash = "sha256:67d5e4dfad52832017018d30a462604c80561aa62a9d548fc2bd4e430b66a352", size = 189966, upload-time = "2025-11-24T03:55:52.458Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/63363422153937d40e1cb349c5081338401f8529a5a4e216865decd981bf/msgspec-0.20.0-cp313-cp313-win_arm64.whl", hash = "sha256:91a52578226708b63a9a13de287b1ec3ed1123e4a088b198143860c087770458", size = 175378, upload-time = "2025-11-24T03:55:53.721Z" }, { url = "https://files.pythonhosted.org/packages/bb/18/62dc13ab0260c7d741dda8dc7f481495b93ac9168cd887dda5929880eef8/msgspec-0.20.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:eead16538db1b3f7ec6e3ed1f6f7c5dec67e90f76e76b610e1ffb5671815633a", size = 196407, upload-time = "2025-11-24T03:55:55.001Z" }, { url = "https://files.pythonhosted.org/packages/dd/1d/b9949e4ad6953e9f9a142c7997b2f7390c81e03e93570c7c33caf65d27e1/msgspec-0.20.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:703c3bb47bf47801627fb1438f106adbfa2998fe586696d1324586a375fca238", size = 188889, upload-time = "2025-11-24T03:55:56.311Z" }, { url = "https://files.pythonhosted.org/packages/1e/19/f8bb2dc0f1bfe46cc7d2b6b61c5e9b5a46c62298e8f4d03bbe499c926180/msgspec-0.20.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6cdb227dc585fb109305cee0fd304c2896f02af93ecf50a9c84ee54ee67dbb42", size = 219691, upload-time = "2025-11-24T03:55:57.908Z" }, { url = "https://files.pythonhosted.org/packages/b8/8e/6b17e43f6eb9369d9858ee32c97959fcd515628a1df376af96c11606cf70/msgspec-0.20.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27d35044dd8818ac1bd0fedb2feb4fbdff4e3508dd7c5d14316a12a2d96a0de0", size = 224918, upload-time = "2025-11-24T03:55:59.322Z" }, { url = "https://files.pythonhosted.org/packages/1c/db/0e833a177db1a4484797adba7f429d4242585980b90882cc38709e1b62df/msgspec-0.20.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4296393a29ee42dd25947981c65506fd4ad39beaf816f614146fa0c5a6c91ae", size = 223436, upload-time = "2025-11-24T03:56:00.716Z" }, { url = "https://files.pythonhosted.org/packages/c3/30/d2ee787f4c918fd2b123441d49a7707ae9015e0e8e1ab51aa7967a97b90e/msgspec-0.20.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:205fbdadd0d8d861d71c8f3399fe1a82a2caf4467bc8ff9a626df34c12176980", size = 227190, upload-time = "2025-11-24T03:56:02.371Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/9c4b58ff11d890d788e700b827db2366f4d11b3313bf136780da7017278b/msgspec-0.20.0-cp314-cp314-win_amd64.whl", hash = "sha256:7dfebc94fe7d3feec6bc6c9df4f7e9eccc1160bb5b811fbf3e3a56899e398a6b", size = 193950, upload-time = "2025-11-24T03:56:03.668Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4e/cab707bf2fa57408e2934e5197fc3560079db34a1e3cd2675ff2e47e07de/msgspec-0.20.0-cp314-cp314-win_arm64.whl", hash = "sha256:2ad6ae36e4a602b24b4bf4eaf8ab5a441fec03e1f1b5931beca8ebda68f53fc0", size = 179018, upload-time = "2025-11-24T03:56:05.038Z" }, { url = "https://files.pythonhosted.org/packages/4c/06/3da3fc9aaa55618a8f43eb9052453cfe01f82930bca3af8cea63a89f3a11/msgspec-0.20.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f84703e0e6ef025663dd1de828ca028774797b8155e070e795c548f76dde65d5", size = 200389, upload-time = "2025-11-24T03:56:06.375Z" }, { url = "https://files.pythonhosted.org/packages/83/3b/cc4270a5ceab40dfe1d1745856951b0a24fd16ac8539a66ed3004a60c91e/msgspec-0.20.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c83fc24dd09cf1275934ff300e3951b3adc5573f0657a643515cc16c7dee131", size = 193198, upload-time = "2025-11-24T03:56:07.742Z" }, { url = "https://files.pythonhosted.org/packages/cd/ae/4c7905ac53830c8e3c06fdd60e3cdcfedc0bbc993872d1549b84ea21a1bd/msgspec-0.20.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f13ccb1c335a124e80c4562573b9b90f01ea9521a1a87f7576c2e281d547f56", size = 225973, upload-time = "2025-11-24T03:56:09.18Z" }, { url = "https://files.pythonhosted.org/packages/d9/da/032abac1de4d0678d99eaeadb1323bd9d247f4711c012404ba77ed6f15ca/msgspec-0.20.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17c2b5ca19f19306fc83c96d85e606d2cc107e0caeea85066b5389f664e04846", size = 229509, upload-time = "2025-11-24T03:56:10.898Z" }, { url = "https://files.pythonhosted.org/packages/69/52/fdc7bdb7057a166f309e0b44929e584319e625aaba4771b60912a9321ccd/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d931709355edabf66c2dd1a756b2d658593e79882bc81aae5964969d5a291b63", size = 230434, upload-time = "2025-11-24T03:56:12.48Z" }, { url = "https://files.pythonhosted.org/packages/cb/fe/1dfd5f512b26b53043884e4f34710c73e294e7cc54278c3fe28380e42c37/msgspec-0.20.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f915d2e540e8a0c93a01ff67f50aebe1f7e22798c6a25873f9fda8d1325f8", size = 231758, upload-time = "2025-11-24T03:56:13.765Z" }, + { url = "https://files.pythonhosted.org/packages/97/f6/9ba7121b8e0c4e0beee49575d1dbc804e2e72467692f0428cf39ceba1ea5/msgspec-0.20.0-cp314-cp314t-win_amd64.whl", hash = "sha256:726f3e6c3c323f283f6021ebb6c8ccf58d7cd7baa67b93d73bfbe9a15c34ab8d", size = 206540, upload-time = "2025-11-24T03:56:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/3e/c5187de84bb2c2ca334ab163fcacf19a23ebb1d876c837f81a1b324a15bf/msgspec-0.20.0-cp314-cp314t-win_arm64.whl", hash = "sha256:93f23528edc51d9f686808a361728e903d6f2be55c901d6f5c92e44c6d546bfc", size = 183011, upload-time = "2025-11-24T03:56:16.442Z" }, ] [[package]] @@ -1899,38 +2392,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] @@ -2030,6 +2578,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, ] [[package]] @@ -2056,6 +2606,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, @@ -2064,6 +2617,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, @@ -2071,6 +2627,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, @@ -2079,6 +2638,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, @@ -2086,6 +2648,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] [[package]] @@ -2095,6 +2660,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/29/99/db44d685f0e257ff0e213ade1964fc459b4a690a73293220e98feb3307cf/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0", size = 590537124, upload-time = "2025-03-07T01:43:53.556Z" }, { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, + { url = "https://files.pythonhosted.org/packages/70/61/7d7b3c70186fb651d0fbd35b01dbfc8e755f69fd58f817f3d0f642df20c3/nvidia_cublas_cu12-12.8.4.1-py3-none-win_amd64.whl", hash = "sha256:47e9b82132fa8d2b4944e708049229601448aaad7e6f296f630f2d1a32de35af", size = 567544208, upload-time = "2025-03-07T01:53:30.535Z" }, ] [[package]] @@ -2104,6 +2670,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/d5/1f/b3bd73445e5cb342727fd24fe1f7b748f690b460acadc27ea22f904502c8/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed", size = 9533318, upload-time = "2025-03-07T01:40:10.421Z" }, { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/41/bc/83f5426095d93694ae39fe1311431b5d5a9bb82e48bf0dd8e19be2765942/nvidia_cuda_cupti_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:bb479dcdf7e6d4f8b0b01b115260399bf34154a1a2e9fe11c85c517d87efd98e", size = 7015759, upload-time = "2025-03-07T01:51:11.355Z" }, ] [[package]] @@ -2113,6 +2680,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, { url = "https://files.pythonhosted.org/packages/eb/d1/e50d0acaab360482034b84b6e27ee83c6738f7d32182b987f9c7a4e32962/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8", size = 43106076, upload-time = "2025-03-07T01:41:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/45/51/52a3d84baa2136cc8df15500ad731d74d3a1114d4c123e043cb608d4a32b/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:7a4b6b2904850fe78e0bd179c4b655c404d4bb799ef03ddc60804247099ae909", size = 73586838, upload-time = "2025-03-07T01:52:13.483Z" }, ] [[package]] @@ -2122,6 +2690,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/75/f865a3b236e4647605ea34cc450900854ba123834a5f1598e160b9530c3a/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d", size = 965265, upload-time = "2025-03-07T01:39:43.533Z" }, { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, + { url = "https://files.pythonhosted.org/packages/30/a5/a515b7600ad361ea14bfa13fb4d6687abf500adc270f19e89849c0590492/nvidia_cuda_runtime_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:c0c6027f01505bfed6c3b21ec546f69c687689aad5f1a377554bc6ca4aa993a8", size = 944318, upload-time = "2025-03-07T01:51:01.794Z" }, ] [[package]] @@ -2134,6 +2703,7 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" }, { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { url = "https://files.pythonhosted.org/packages/3d/90/0bd6e586701b3a890fd38aa71c387dab4883d619d6e5ad912ccbd05bfd67/nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e", size = 692992268, upload-time = "2025-06-06T21:55:18.114Z" }, ] [[package]] @@ -2146,6 +2716,7 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" }, { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ec/ce1629f1e478bb5ccd208986b5f9e0316a78538dd6ab1d0484f012f8e2a1/nvidia_cufft_cu12-11.3.3.83-py3-none-win_amd64.whl", hash = "sha256:7a64a98ef2a7c47f905aaf8931b69a3a43f27c55530c698bb2ed7c75c0b42cb7", size = 192216559, upload-time = "2025-03-07T01:53:57.106Z" }, ] [[package]] @@ -2164,6 +2735,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/45/5e/92aa15eca622a388b80fbf8375d4760738df6285b1e92c43d37390a33a9a/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd", size = 63625754, upload-time = "2025-03-07T01:46:10.735Z" }, { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/b9/75/70c05b2f3ed5be3bb30b7102b6eb78e100da4bbf6944fd6725c012831cab/nvidia_curand_cu12-10.3.9.90-py3-none-win_amd64.whl", hash = "sha256:f149a8ca457277da854f89cf282d6ef43176861926c7ac85b2a0fbd237c587ec", size = 62765309, upload-time = "2025-03-07T01:54:20.478Z" }, ] [[package]] @@ -2178,6 +2750,7 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" }, { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/76ca8551b8a84146ffa189fec81c26d04adba4bc0dbe09cd6e6fd9b7de04/nvidia_cusolver_cu12-11.7.3.90-py3-none-win_amd64.whl", hash = "sha256:4a550db115fcabc4d495eb7d39ac8b58d4ab5d8e63274d3754df1c0ad6a22d34", size = 256720438, upload-time = "2025-03-07T01:54:39.898Z" }, ] [[package]] @@ -2190,6 +2763,7 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" }, { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, + { url = "https://files.pythonhosted.org/packages/62/07/f3b2ad63f8e3d257a599f422ae34eb565e70c41031aecefa3d18b62cabd1/nvidia_cusparse_cu12-12.5.8.93-py3-none-win_amd64.whl", hash = "sha256:9a33604331cb2cac199f2e7f5104dfbb8a5a898c367a53dfda9ff2acb6b6b4dd", size = 284937404, upload-time = "2025-03-07T01:55:07.742Z" }, ] [[package]] @@ -2199,6 +2773,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" }, { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d8/a6b0d0d0c2435e9310f3e2bb0d9c9dd4c33daef86aa5f30b3681defd37ea/nvidia_cusparselt_cu12-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f67fbb5831940ec829c9117b7f33807db9f9678dc2a617fbe781cac17b4e1075", size = 271020911, upload-time = "2025-02-26T00:14:47.204Z" }, ] [[package]] @@ -2217,6 +2792,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, { url = "https://files.pythonhosted.org/packages/2a/a2/8cee5da30d13430e87bf99bb33455d2724d0a4a9cb5d7926d80ccb96d008/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7", size = 38386204, upload-time = "2025-03-07T01:49:43.612Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d7/34f02dad2e30c31b10a51f6b04e025e5dd60e5f936af9045a9b858a05383/nvidia_nvjitlink_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:bd93fbeeee850917903583587f4fc3a4eafa022e34572251368238ab5e6bd67f", size = 268553710, upload-time = "2025-03-07T01:56:24.13Z" }, ] [[package]] @@ -2235,6 +2811,7 @@ source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/10/c0/1b303feea90d296f6176f32a2a70b5ef230f9bdeb3a72bddb0dc922dc137/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615", size = 91161, upload-time = "2025-03-07T01:42:23.922Z" }, { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/9f/99/4c9c0c329bf9fc125008c3b54c7c94c0023518d06fc025ae36431375e1fe/nvidia_nvtx_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:619c8304aedc69f02ea82dd244541a83c3d9d40993381b3b590f1adaed3db41e", size = 56492, upload-time = "2025-03-07T01:52:24.69Z" }, ] [[package]] @@ -2267,9 +2844,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/3e/92/2d038d096f29179c7 wheels = [ { url = "https://files.pythonhosted.org/packages/45/c6/2502f416d46be3ec08bb66d696cccffb57781a499e3ff2e4d7c174af4e8f/openai_harmony-0.0.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:029ec25ca74abe48fdb58eb9fdd2a8c1618581fc33ce8e5653f8a1ffbfbd9326", size = 2627806, upload-time = "2025-11-05T19:06:57.063Z" }, { url = "https://files.pythonhosted.org/packages/d3/d2/ce6953ca87db9cae3e775024184da7d1c5cb88cead19a2d75b42f00a959c/openai_harmony-0.0.8-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4f709815924ec325b9a890e6ab2bbb0ceec8e319a4e257328eb752cf36b2efc", size = 2948463, upload-time = "2025-11-05T19:06:48.17Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4c/b553c9651662d6ce102ca7f3629d268b23df1abe5841e24bed81e8a8e949/openai_harmony-0.0.8-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5cfcfd963b50a41fc656c84d3440ca6eecdccd6c552158ce790b8f2e33dfb5a9", size = 2704083, upload-time = "2025-11-05T19:06:50.205Z" }, + { url = "https://files.pythonhosted.org/packages/9b/af/4eec8f9ab9c27bcdb444460c72cf43011d176fc44c79d6e113094ca1e152/openai_harmony-0.0.8-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a3a16972aa1cee38ea958470cd04ac9a2d5ac38fdcf77ab686611246220c158", size = 2959765, upload-time = "2025-11-05T19:06:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/11/3c/33f3374e4624e0e776f6b13b73c45a7ead7f9c4529f8369ed5bfcaa30cac/openai_harmony-0.0.8-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4d5cfa168e74d08f8ba6d58a7e49bc7daef4d58951ec69b66b0d56f4927a68d", size = 3427031, upload-time = "2025-11-05T19:06:51.829Z" }, { url = "https://files.pythonhosted.org/packages/25/3f/1a192b93bb47c6b44cd98ba8cc1d3d2a9308f1bb700c3017e6352da11bda/openai_harmony-0.0.8-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c007d277218a50db8839e599ed78e0fffe5130f614c3f6d93ae257f282071a29", size = 2953260, upload-time = "2025-11-05T19:06:55.406Z" }, { url = "https://files.pythonhosted.org/packages/5b/f8/93b582cad3531797c3db7c2db5400fd841538ccddfd9f5e3df61be99a630/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8565d4f5a0638da1bffde29832ed63c9e695c558611053add3b2dc0b56c92dbc", size = 3127044, upload-time = "2025-11-05T19:06:59.553Z" }, + { url = "https://files.pythonhosted.org/packages/1d/10/4327dbf87f75ae813405fd9a9b4a5cde63d506ffed0a096a440a4cabd89c/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:cbaa3bda75ef0d8836e1f8cc84af62f971b1d756d740efc95c38c3e04c0bfde2", size = 2932931, upload-time = "2025-11-05T19:07:01.437Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c8/1774eec4f6f360ef57618fb8f52e3d3af245b2491bd0297513aa09eec04b/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:772922a9bd24e133950fad71eb1550836f415a88e8c77870e12d0c3bd688ddc2", size = 2996140, upload-time = "2025-11-05T19:07:03.438Z" }, { url = "https://files.pythonhosted.org/packages/60/c3/3d1e01e2dba517a91760e4a03e4f20ffc75039a6fe584d0e6f9b5c78fd15/openai_harmony-0.0.8-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:007b0476a1f331f8130783f901f1da6f5a7057af1a4891f1b6a31dec364189b5", size = 3205080, upload-time = "2025-11-05T19:07:05.078Z" }, + { url = "https://files.pythonhosted.org/packages/14/63/119de431572d7c70a7bf1037034a9be6ed0a7502a7498ba7302bca5b3242/openai_harmony-0.0.8-cp38-abi3-win32.whl", hash = "sha256:a9b5f893326b28d9e935ade14b4f655f5a840942473bc89b201c25f7a15af9cf", size = 2082457, upload-time = "2025-11-05T19:07:09.631Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/c83cf5a206c263ee70448a5ae4264682555f4d0b5bed0d2cc6ca1108103d/openai_harmony-0.0.8-cp38-abi3-win_amd64.whl", hash = "sha256:39d44f0d8f466bd56698e7ead708bead3141e27b9b87e3ab7d5a6d0e4a869ee5", size = 2438369, upload-time = "2025-11-05T19:07:08.1Z" }, ] [[package]] @@ -2327,12 +2911,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, @@ -2345,6 +2931,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, @@ -2380,30 +2967,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] [[package]] @@ -2439,6 +3035,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, @@ -2448,6 +3047,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, @@ -2457,12 +3059,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, @@ -2472,12 +3080,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] @@ -2579,37 +3193,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] @@ -2621,7 +3275,10 @@ sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2 wheels = [ { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] @@ -2635,16 +3292,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[package]] @@ -2680,30 +3343,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] [[package]] @@ -2768,27 +3436,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, @@ -2868,7 +3568,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, + { name = "coverage", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-test') or (platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-test') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-test') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-test') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, { name = "pluggy", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, { name = "pytest", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, ] @@ -2946,18 +3646,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] @@ -2979,27 +3683,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -3012,20 +3730,37 @@ dependencies = [ sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, ] [[package]] @@ -3086,37 +3821,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/0a/7dcffeebe0fcac45a1f9caf80712002d3cbd66d7d69d719315ee142b280f/regex-2026.3.32-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f5747501b69299c6b0b047853771e4ed390510bada68cb16da9c9c2078343f7", size = 292078, upload-time = "2026-03-28T21:46:29.789Z" }, { url = "https://files.pythonhosted.org/packages/e3/ec/988486058ef49eb931476419bae00f164c4ceb44787c45dc7a54b7de0ea4/regex-2026.3.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db976be51375bca900e008941639448d148c655c9545071965d0571ecc04f5d0", size = 289786, upload-time = "2026-03-28T21:46:31.415Z" }, { url = "https://files.pythonhosted.org/packages/4a/cf/1955bb5567bc491bd63068e17f75ab0c9ff5e9d08466beec7e347f5e768d/regex-2026.3.32-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66a5083c3ffe5a5a95f8281ea47a88072d4f24001d562d1d9d28d4cdc005fec5", size = 796431, upload-time = "2026-03-28T21:46:33.101Z" }, + { url = "https://files.pythonhosted.org/packages/27/8a/67fcbca511b792107540181ee0690df6de877bfbcb41b7ecae7028025ca5/regex-2026.3.32-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e83ce8008b48762be296f1401f19afd9ea29f3d035d1974e0cecb74e9afbd1df", size = 865785, upload-time = "2026-03-28T21:46:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/0677bc44f2c28305edcabc11933777b9ad34e9e8ded7ba573d24e4bc3ee7/regex-2026.3.32-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3aa21bad31db904e0b9055e12c8282df62d43169c4a9d2929407060066ebc74", size = 913593, upload-time = "2026-03-28T21:46:36.835Z" }, { url = "https://files.pythonhosted.org/packages/0a/fe/661043d1c263b0d9d10c6ff4e9c9745f3df9641c62b51f96a3473638e7ce/regex-2026.3.32-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f54840bea73541652f1170dc63402a5b776fc851ad36a842da9e5163c1f504a0", size = 801512, upload-time = "2026-03-28T21:46:38.587Z" }, + { url = "https://files.pythonhosted.org/packages/ff/27/74c986061380e1811a46cf04cdf9c939db9f8c0e63953eddfe37ffd633ea/regex-2026.3.32-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ffbadc647325dd4e3118269bda93ded1eb5f5b0c3b7ba79a3da9fbd04f248e9", size = 776182, upload-time = "2026-03-28T21:46:40.69Z" }, { url = "https://files.pythonhosted.org/packages/b6/c8/d833397b70cd1bacfcdc0a611f0e2c1f5b91fee8eedd88affcee770cbbb6/regex-2026.3.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66d3126afe7eac41759cd5f0b3b246598086e88e70527c0d68c9e615b81771c4", size = 785837, upload-time = "2026-03-28T21:46:42.926Z" }, + { url = "https://files.pythonhosted.org/packages/e0/53/fa226b72989b5b93db6926fab5478115e085dfcf077e18d2cb386be0fd23/regex-2026.3.32-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f785f44a44702dea89b28bce5bc82552490694ce4e144e21a4f0545e364d2150", size = 860612, upload-time = "2026-03-28T21:46:44.8Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/bdd2fc0c055a1b15702bd4084829bbb6b06095f27990e5bee52b2898ea03/regex-2026.3.32-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b7836aa13721dbdef658aebd11f60d00de633a95726521860fe1f6be75fa225a", size = 765285, upload-time = "2026-03-28T21:46:46.625Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/21f5e2a35a191b27e5a47cccb3914c99e139b49b1342d3f36e64e8cc60f7/regex-2026.3.32-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5336b1506142eb0f23c96fb4a34b37c4fefd4fed2a7042069f3c8058efe17855", size = 851963, upload-time = "2026-03-28T21:46:48.341Z" }, { url = "https://files.pythonhosted.org/packages/18/f4/04ed04ebf335a44083695c22772be6a42efa31900415555563acf02cb4de/regex-2026.3.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b56993a7aeb4140c4770f4f7965c9e5af4f024457d06e23c01b0d47501cb18ed", size = 788332, upload-time = "2026-03-28T21:46:50.454Z" }, + { url = "https://files.pythonhosted.org/packages/21/25/5355908f479d0dc13d044f88270cdcabc8723efc12e4c2b19e5a94ff1a96/regex-2026.3.32-cp312-cp312-win32.whl", hash = "sha256:d363660f9ef8c734495598d2f3e527fb41f745c73159dc0d743402f049fb6836", size = 266847, upload-time = "2026-03-28T21:46:52.125Z" }, + { url = "https://files.pythonhosted.org/packages/00/e5/3be71c781a031db5df00735b613895ad5fdbf86c6e3bbea5fbbd7bfb5902/regex-2026.3.32-cp312-cp312-win_amd64.whl", hash = "sha256:c9f261ad3cd97257dc1d9355bfbaa7dd703e06574bffa0fa8fe1e31da915ee38", size = 278034, upload-time = "2026-03-28T21:46:54.096Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/27f1e0b1eea4faa99c66daca34130af20c44fae0237bbc98b87999dbc4a8/regex-2026.3.32-cp312-cp312-win_arm64.whl", hash = "sha256:89e50667e7e8c0e7903e4d644a2764fffe9a3a5d6578f72ab7a7b4205bf204b7", size = 270673, upload-time = "2026-03-28T21:46:56.046Z" }, { url = "https://files.pythonhosted.org/packages/bd/ba/9c1819f302b42b5fbd4139ead6280e9ec37d19bbe33379df0039b2a57bb4/regex-2026.3.32-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c6d9c6e783b348f719b6118bb3f187b2e138e3112576c9679eb458cc8b2e164b", size = 490394, upload-time = "2026-03-28T21:46:58.112Z" }, { url = "https://files.pythonhosted.org/packages/5b/0b/f62b0ce79eb83ca82fffea1736289d29bc24400355968301406789bcebd2/regex-2026.3.32-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f21ae18dfd15752cdd98d03cbd7a3640be826bfd58482a93f730dbd24d7b9fb", size = 291993, upload-time = "2026-03-28T21:47:00.198Z" }, { url = "https://files.pythonhosted.org/packages/e7/d8/ba0f8f81f88cd20c0b27acc123561ac5495ea33f800f0b8ebed2038b23eb/regex-2026.3.32-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:844d88509c968dd44b30daeefac72b038b1bf31ac372d5106358ab01d393c48b", size = 289618, upload-time = "2026-03-28T21:47:02.269Z" }, { url = "https://files.pythonhosted.org/packages/fd/0d/b47a0e68bc511c195ff129c0311a4cd79b954b8676193a9d03a97c623a91/regex-2026.3.32-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fc918cd003ba0d066bf0003deb05a259baaaab4dc9bd4f1207bbbe64224857a", size = 796427, upload-time = "2026-03-28T21:47:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/32b05aa8fde7789ba316533c0f30e87b6b5d38d6d7f8765eadc5aab84671/regex-2026.3.32-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbc458a292aee57d572075f22c035fa32969cdb7987d454e3e34d45a40a0a8b4", size = 865850, upload-time = "2026-03-28T21:47:05.982Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/828d8095501f237b83f630d4069eea8c0e5cb6a204e859cf0b67c223ce12/regex-2026.3.32-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:987cdfcfb97a249abc3601ad53c7de5c370529f1981e4c8c46793e4a1e1bfe8e", size = 913578, upload-time = "2026-03-28T21:47:08.172Z" }, { url = "https://files.pythonhosted.org/packages/0f/f8/acf1eb80f58852e85bd39a6ddfa78ce2243ddc8de8da7582e6ba657da593/regex-2026.3.32-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5d88fa37ba5e8a80ca8d956b9ea03805cfa460223ac94b7d4854ee5e30f3173", size = 801536, upload-time = "2026-03-28T21:47:10.206Z" }, + { url = "https://files.pythonhosted.org/packages/9f/05/986cdf8d12693451f5889aaf4ea4f65b2c49b1152ae814fa1fb75439e40b/regex-2026.3.32-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d082be64e51671dd5ee1c208c92da2ddda0f2f20d8ef387e57634f7e97b6aae", size = 776226, upload-time = "2026-03-28T21:47:12.891Z" }, { url = "https://files.pythonhosted.org/packages/32/02/945a6a2348ca1c6608cb1747275c8affd2ccd957d4885c25218a86377912/regex-2026.3.32-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1d7fa44aece1fa02b8927441614c96520253a5cad6a96994e3a81e060feed55", size = 785933, upload-time = "2026-03-28T21:47:14.795Z" }, + { url = "https://files.pythonhosted.org/packages/53/12/c5bab6cc679ad79a45427a98c4e70809586ac963c5ad54a9217533c4763e/regex-2026.3.32-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d478a2ca902b6ef28ffc9521e5f0f728d036abe35c0b250ee8ae78cfe7c5e44e", size = 860671, upload-time = "2026-03-28T21:47:16.985Z" }, + { url = "https://files.pythonhosted.org/packages/bf/68/8d85f98c2443469facabef62b82b851d369b13f92bec2ca7a3808deaa47b/regex-2026.3.32-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2820d2231885e97aff0fcf230a19ebd5d2b5b8a1ba338c20deb34f16db1c7897", size = 765335, upload-time = "2026-03-28T21:47:18.872Z" }, + { url = "https://files.pythonhosted.org/packages/89/a7/d8a9c270916107a501fca63b748547c6c77e570d19f16a29b557ce734f3d/regex-2026.3.32-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc8ced733d6cd9af5e412f256a32f7c61cd2d7371280a65c689939ac4572499f", size = 851913, upload-time = "2026-03-28T21:47:20.793Z" }, { url = "https://files.pythonhosted.org/packages/f4/8e/03d392b26679914ccf21f83d18ad4443232d2f8c3e2c30a962d4e3918d9c/regex-2026.3.32-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847087abe98b3c1ebf1eb49d6ef320dbba75a83ee4f83c94704580f1df007dd4", size = 788447, upload-time = "2026-03-28T21:47:22.628Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/692227d23535a50604333068b39eb262626db780ab1e1b19d83fc66853aa/regex-2026.3.32-cp313-cp313-win32.whl", hash = "sha256:d21a07edddb3e0ca12a8b8712abc8452481c3d3db19ae87fc94e9842d005964b", size = 266834, upload-time = "2026-03-28T21:47:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/13e4e56adc16ba607cffa1fe880f233eb9ded8ab8a8580619683c9e4ce48/regex-2026.3.32-cp313-cp313-win_amd64.whl", hash = "sha256:3c054e39a9f85a3d76c62a1d50c626c5e9306964eaa675c53f61ff7ec1204bbb", size = 277972, upload-time = "2026-03-28T21:47:26.627Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1c/80a86dbb2b416fec003b1801462bdcebbf1d43202ed5acb176e99c1ba369/regex-2026.3.32-cp313-cp313-win_arm64.whl", hash = "sha256:b2e9c2ea2e93223579308263f359eab8837dc340530b860cb59b713651889f14", size = 270649, upload-time = "2026-03-28T21:47:28.551Z" }, { url = "https://files.pythonhosted.org/packages/58/08/e38372da599dc1c39c599907ec535016d110034bd3701ce36554f59767ef/regex-2026.3.32-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5d86e3fb08c94f084a625c8dc2132a79a3a111c8bf6e2bc59351fa61753c2f6e", size = 494495, upload-time = "2026-03-28T21:47:30.642Z" }, { url = "https://files.pythonhosted.org/packages/5f/27/6e29ece8c9ce01001ece1137fa21c8707529c2305b22828f63623b0eb262/regex-2026.3.32-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b6f366a5ef66a2df4d9e68035cfe9f0eb8473cdfb922c37fac1d169b468607b0", size = 293988, upload-time = "2026-03-28T21:47:32.553Z" }, { url = "https://files.pythonhosted.org/packages/e1/98/8752e18bb87a2fe728b73b0f83c082eb162a470766063f8028759fb26844/regex-2026.3.32-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b8fca73e16c49dd972ce3a88278dfa5b93bf91ddef332a46e9443abe21ca2f7c", size = 292634, upload-time = "2026-03-28T21:47:34.651Z" }, { url = "https://files.pythonhosted.org/packages/7f/7b/d7729fe294e23e9c7c3871cb69d49059fa7d65fd11e437a2cbea43f6615d/regex-2026.3.32-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b953d9d496d19786f4d46e6ba4b386c6e493e81e40f9c5392332458183b0599d", size = 810532, upload-time = "2026-03-28T21:47:36.839Z" }, + { url = "https://files.pythonhosted.org/packages/fd/49/4dae7b000659f611b17b9c1541fba800b0569e4060debc4635ef1b23982c/regex-2026.3.32-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b565f25171e04d4fad950d1fa837133e3af6ea6f509d96166eed745eb0cf63bc", size = 871919, upload-time = "2026-03-28T21:47:39.192Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/aa8ad3977b9399861db3df62b33fe5fef6932ee23a1b9f4f357f58f2094b/regex-2026.3.32-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f28eac18a8733a124444643a66ac96fef2c0ad65f50034e0a043b90333dc677f", size = 916550, upload-time = "2026-03-28T21:47:41.618Z" }, { url = "https://files.pythonhosted.org/packages/c8/c0/6379d7f5b59ff0656ba49cf666d5013ecee55e83245275b310b0ffc79143/regex-2026.3.32-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cdd508664430dd51b8888deb6c5b416d8de046b2e11837254378d31febe4a98", size = 814988, upload-time = "2026-03-28T21:47:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/2c/af/2dfddc64074bd9b70e27e170ee9db900542e2870210b489ad4471416ba86/regex-2026.3.32-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c35d097f509cf7e40d20d5bee548d35d6049b36eb9965e8d43e4659923405b9", size = 786337, upload-time = "2026-03-28T21:47:46.076Z" }, { url = "https://files.pythonhosted.org/packages/eb/2f/4eb8abd705236402b4fe0e130971634deffb1855e2028bf02a2b7c0e841c/regex-2026.3.32-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:85c9b0c131427470a6423baa0a9330be6fd8c3630cc3ee6fdee03360724cbec5", size = 800029, upload-time = "2026-03-28T21:47:48.356Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2c/77d9ca2c9df483b51b4b1291c96d79c9ae301077841c4db39bc822f6b4c6/regex-2026.3.32-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:e50af656c15e2723eeb7279c0837e07accc594b95ec18b86821a4d44b51b24bf", size = 865843, upload-time = "2026-03-28T21:47:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/306f477a509f4eed699071b1f031d89edd5a2b5fa28c8ede5b2638eaba82/regex-2026.3.32-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4bc32b4dbdb4f9f300cf9f38f8ea2ce9511a068ffaa45ac1373ee7a943f1d810", size = 772473, upload-time = "2026-03-28T21:47:52.771Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f6/54bd83ec46ac037de2beb049afc9dd5d2769c6ecaadf7856254ce610e62a/regex-2026.3.32-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3e5d1802cba785210a4a800e63fcee7a228649a880f3bf7f2aadccb151a834b", size = 856805, upload-time = "2026-03-28T21:47:55.04Z" }, { url = "https://files.pythonhosted.org/packages/37/e8/ee0e7d14de1fc6582d5782f072db6c61465a38a4142f88e175dda494b536/regex-2026.3.32-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ef250a3f5e93182193f5c927c5e9575b2cb14b80d03e258bc0b89cc5de076b60", size = 801875, upload-time = "2026-03-28T21:47:57.434Z" }, + { url = "https://files.pythonhosted.org/packages/8a/06/0fa9daca59d07b6aabd8e0468d3b86fd578576a157206fbcddbfc2298f7d/regex-2026.3.32-cp313-cp313t-win32.whl", hash = "sha256:9cf7036dfa2370ccc8651521fcbb40391974841119e9982fa312b552929e6c85", size = 269892, upload-time = "2026-03-28T21:47:59.674Z" }, + { url = "https://files.pythonhosted.org/packages/13/47/77f16b5ad9f10ca574f03d84a354b359b0ac33f85054f2f2daafc9f7b807/regex-2026.3.32-cp313-cp313t-win_amd64.whl", hash = "sha256:c940e00e8d3d10932c929d4b8657c2ea47d2560f31874c3e174c0d3488e8b865", size = 281318, upload-time = "2026-03-28T21:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/db4446faaea8d01c8315c9c89c7dc6abbb3305e8e712e9b23936095c4d58/regex-2026.3.32-cp313-cp313t-win_arm64.whl", hash = "sha256:ace48c5e157c1e58b7de633c5e257285ce85e567ac500c833349c363b3df69d4", size = 272366, upload-time = "2026-03-28T21:48:03.748Z" }, { url = "https://files.pythonhosted.org/packages/32/68/ff024bf6131b7446a791a636dbbb7fa732d586f33b276d84b3460ea49393/regex-2026.3.32-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a416ee898ecbc5d8b283223b4cf4d560f93244f6f7615c1bd67359744b00c166", size = 490430, upload-time = "2026-03-28T21:48:05.654Z" }, { url = "https://files.pythonhosted.org/packages/61/72/039d9164817ee298f2a2d0246001afe662241dcbec0eedd1fe03e2a2555e/regex-2026.3.32-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d76d62909bfb14521c3f7cfd5b94c0c75ec94b0a11f647d2f604998962ec7b6c", size = 291948, upload-time = "2026-03-28T21:48:07.666Z" }, { url = "https://files.pythonhosted.org/packages/06/9d/77f684d90ffe3e99b828d3cabb87a0f1601d2b9decd1333ff345809b1d02/regex-2026.3.32-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:631f7d95c83f42bccfe18946a38ad27ff6b6717fb4807e60cf24860b5eb277fc", size = 289786, upload-time = "2026-03-28T21:48:09.562Z" }, { url = "https://files.pythonhosted.org/packages/83/70/bd76069a0304e924682b2efd8683a01617a7e1da9b651af73039d8da76a4/regex-2026.3.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12917c6c6813ffcdfb11680a04e4d63c5532b88cf089f844721c5f41f41a63ad", size = 796672, upload-time = "2026-03-28T21:48:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/c2d7d9a5671e111a2c16d57e0cb03e1ce35b28a115901590528aa928bb5b/regex-2026.3.32-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e221b615f83b15887636fcb90ed21f1a19541366f8b7ba14ba1ad8304f4ded4", size = 866556, upload-time = "2026-03-28T21:48:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b9/9921a31931d0bc3416ac30205471e0e2ed60dcbd16fc922bbd69b427322b/regex-2026.3.32-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f9ae4755fa90f1dc2d0d393d572ebc134c0fe30fcfc0ab7e67c1db15f192041", size = 912787, upload-time = "2026-03-28T21:48:16.548Z" }, { url = "https://files.pythonhosted.org/packages/41/ab/2c1bc8ab99f63cdabdbc7823af8f4cfcd6ddbb2babf01861826c3f1ad44d/regex-2026.3.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a094e9dcafedfb9d333db5cf880304946683f43a6582bb86688f123335122929", size = 800879, upload-time = "2026-03-28T21:48:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/49/e5/0be716eb2c0b2ae3a439e44432534e82b2f81848af64cb21c0473ad8ae46/regex-2026.3.32-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1cecea3e477af105f32ef2119b8d895f297492e41d317e60d474bc4bffd62ff", size = 776332, upload-time = "2026-03-28T21:48:21.163Z" }, { url = "https://files.pythonhosted.org/packages/26/80/114a61bd25dec7d1070930eaef82aadf9b05961a37629e7cca7bc3fc2257/regex-2026.3.32-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f26262900edd16272b6360014495e8d68379c6c6e95983f9b7b322dc928a1194", size = 786384, upload-time = "2026-03-28T21:48:23.277Z" }, + { url = "https://files.pythonhosted.org/packages/0c/78/be0a6531f8db426e8e60d6356aeef8e9cc3f541655a648c4968b63c87a88/regex-2026.3.32-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb22fa9ee6a0acb22fc9aecce5f9995fe4d2426ed849357d499d62608fbd7f9", size = 861381, upload-time = "2026-03-28T21:48:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/45/b1/e5076fbe45b8fb39672584b1b606d512f5bd3a43155be68a95f6b88c1fc5/regex-2026.3.32-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b9118a78e031a2e4709cd2fcc3028432e89b718db70073a8da574c249b5b249", size = 765434, upload-time = "2026-03-28T21:48:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/da/fd65d68b897f8b52b1390d20d776fa753582484724a9cb4f4c26de657ae5/regex-2026.3.32-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b193ed199848aa96618cd5959c1582a0bf23cd698b0b900cb0ffe81b02c8659c", size = 851501, upload-time = "2026-03-28T21:48:29.884Z" }, { url = "https://files.pythonhosted.org/packages/e8/d6/1e9c991c32022a9312e9124cc974961b3a2501338de2cd1cce75a3612d7a/regex-2026.3.32-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:10fb2aaae1aaadf7d43c9f3c2450404253697bf8b9ce360bd5418d1d16292298", size = 788076, upload-time = "2026-03-28T21:48:32.025Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5b/b23c72f6d607cbb24ef42acf0c7c2ef4eee1377a9f7ba43b312f889edfbb/regex-2026.3.32-cp314-cp314-win32.whl", hash = "sha256:110ba4920721374d16c4c8ea7ce27b09546d43e16aea1d7f43681b5b8f80ba61", size = 272255, upload-time = "2026-03-28T21:48:34.355Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ec/32bbcc42366097a8cea2c481e02964be6c6fa5ccfb0fa9581686af0bec5f/regex-2026.3.32-cp314-cp314-win_amd64.whl", hash = "sha256:245667ad430745bae6a1e41081872d25819d86fbd9e0eec485ba00d9f78ad43d", size = 281160, upload-time = "2026-03-28T21:48:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/89038a028cb68e719fa03ab1ad603649fc199bcda12270d2ac7b471b8f5d/regex-2026.3.32-cp314-cp314-win_arm64.whl", hash = "sha256:1ca02ff0ef33e9d8276a1fcd6d90ff6ea055a32c9149c0050b5b67e26c6d2c51", size = 273688, upload-time = "2026-03-28T21:48:38.976Z" }, { url = "https://files.pythonhosted.org/packages/30/6e/87caccd608837a1fa4f8c7edc48e206103452b9bbc94fc724fa39340e807/regex-2026.3.32-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:51fb7e26f91f9091fd8ec6a946f99b15d3bc3667cb5ddc73dd6cb2222dd4a1cc", size = 494506, upload-time = "2026-03-28T21:48:41.327Z" }, { url = "https://files.pythonhosted.org/packages/16/53/a922e6b24694d70bdd68fc3fd076950e15b1b418cff9d2cc362b3968d86f/regex-2026.3.32-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:51a93452034d671b0e21b883d48ea66c5d6a05620ee16a9d3f229e828568f3f0", size = 293986, upload-time = "2026-03-28T21:48:43.481Z" }, { url = "https://files.pythonhosted.org/packages/60/e4/0cb32203c1aebad0577fcd5b9af1fe764869e617d5234bc6a0ad284299ea/regex-2026.3.32-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:03c2ebd15ff51e7b13bb3dc28dd5ac18cd39e59ebb40430b14ae1a19e833cff1", size = 292677, upload-time = "2026-03-28T21:48:45.772Z" }, { url = "https://files.pythonhosted.org/packages/f0/f8/5006b70291469d4174dd66ad162802e2f68419c0f2a7952d0c76c1288cfa/regex-2026.3.32-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bf2f3c2c5bd8360d335c7dcd4a9006cf1dabae063ee2558ee1b07bbc8a20d88", size = 810661, upload-time = "2026-03-28T21:48:48.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9b/438763a20d22cd1f65f95c8f030dd25df2d80a941068a891d21a5f240456/regex-2026.3.32-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a4a3189a99ecdd1c13f42513ab3fc7fa8311b38ba7596dd98537acb8cd9acc3", size = 872156, upload-time = "2026-03-28T21:48:50.739Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5b/1341287887ac982ed9f5f60125e440513ffe354aa7e3681940495af7c12a/regex-2026.3.32-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c0bbfbd38506e1ea96a85da6782577f06239cb9fcf9696f1ea537c980c0680b", size = 916749, upload-time = "2026-03-28T21:48:53.57Z" }, { url = "https://files.pythonhosted.org/packages/42/e2/1d2b48b8e94debfffc6fefb84d2a86a178cc208652a1d6493d5f29821c70/regex-2026.3.32-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8aaf8ee8f34b677f90742ca089b9c83d64bdc410528767273c816a863ed57327", size = 814788, upload-time = "2026-03-28T21:48:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d9/7dacb34c43adaeb954518d851f3e5d3ce495ac00a9d6010e3b4b59917c4a/regex-2026.3.32-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ea568832eca219c2be1721afa073c1c9eb8f98a9733fdedd0a9747639fc22a5", size = 786594, upload-time = "2026-03-28T21:48:58.404Z" }, { url = "https://files.pythonhosted.org/packages/ea/72/28295068c92dbd6d3ce4fd22554345cf504e957cc57dadeda4a64fa86a57/regex-2026.3.32-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e4c8fa46aad1a11ae2f8fcd1c90b9d55e18925829ac0d98c5bb107f93351745", size = 800167, upload-time = "2026-03-28T21:49:01.226Z" }, + { url = "https://files.pythonhosted.org/packages/ca/17/b10745adeca5b8d52da050e7c746137f5d01dabc6dbbe6e8d9d821dc65c1/regex-2026.3.32-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cec365d44835b043d7b3266487797639d07d621bec9dc0ea224b00775797cc1", size = 865906, upload-time = "2026-03-28T21:49:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/45/9d/1acbcce765044ac0c87f453f4876e0897f7a61c10315262f960184310798/regex-2026.3.32-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:09e26cad1544d856da85881ad292797289e4406338afe98163f3db9f7fac816c", size = 772642, upload-time = "2026-03-28T21:49:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/24/41/1ef8b4811355ad7b9d7579d3aeca00f18b7bc043ace26c8c609b9287346d/regex-2026.3.32-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6062c4ef581a3e9e503dccf4e1b7f2d33fdc1c13ad510b287741ac73bc4c6b27", size = 856927, upload-time = "2026-03-28T21:49:09.373Z" }, { url = "https://files.pythonhosted.org/packages/97/b1/0dc1d361be80ec1b8b707ada041090181133a7a29d438e432260a4b26f9a/regex-2026.3.32-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88ebc0783907468f17fca3d7821b30f9c21865a721144eb498cb0ff99a67bcac", size = 801910, upload-time = "2026-03-28T21:49:11.818Z" }, + { url = "https://files.pythonhosted.org/packages/b5/db/1a23f767fa250844772a9464306d34e0fafe2c317303b88a1415096b6324/regex-2026.3.32-cp314-cp314t-win32.whl", hash = "sha256:e480d3dac06c89bc2e0fd87524cc38c546ac8b4a38177650745e64acbbcfdeba", size = 275714, upload-time = "2026-03-28T21:49:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2b/616d31b125ca76079d74d6b1d84ec0860ffdb41c379151135d06e35a8633/regex-2026.3.32-cp314-cp314t-win_amd64.whl", hash = "sha256:67015a8162d413af9e3309d9a24e385816666fbf09e48e3ec43342c8536f7df6", size = 285722, upload-time = "2026-03-28T21:49:16.642Z" }, + { url = "https://files.pythonhosted.org/packages/7e/91/043d9a00d6123c5fa22a3dc96b10445ce434a8110e1d5e53efb01f243c8b/regex-2026.3.32-cp314-cp314t-win_arm64.whl", hash = "sha256:1a6ac1ed758902e664e0d95c1ee5991aa6fb355423f378ed184c6ec47a1ec0e9", size = 275700, upload-time = "2026-03-28T21:49:19.348Z" }, ] [[package]] @@ -3178,45 +3958,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, ] [[package]] @@ -3225,12 +4065,23 @@ version = "0.15.8" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -3254,9 +4105,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, ] [[package]] @@ -3276,18 +4135,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, ] [[package]] @@ -3296,7 +4163,7 @@ version = "1.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'x86_64' and sys_platform == 'darwin' and extra != 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-18-inference-endpoint-bfcl') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "(platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-test') or (platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-test') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-test') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-test') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'darwin' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-dev') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-performance') or (sys_platform == 'linux' and extra == 'extra-18-inference-endpoint-bfcl' and extra == 'extra-18-inference-endpoint-test')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -3308,6 +4175,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, @@ -3316,6 +4185,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, @@ -3324,6 +4195,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, @@ -3332,6 +4205,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, @@ -3340,6 +4215,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] @@ -3373,26 +4250,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, + { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, + { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" }, { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" }, { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" }, { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" }, { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" }, { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" }, { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" }, { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" }, { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" }, { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" }, ] [[package]] @@ -3464,6 +4356,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, ] [[package]] @@ -3609,24 +4503,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] @@ -3685,30 +4589,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, ] [[package]] @@ -3723,9 +4632,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, ] [[package]] @@ -3740,24 +4658,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] @@ -3790,6 +4720,7 @@ dependencies = [ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/0c/d8f77363a7a3350c96e6c9db4ffb101d1c0487cc0b8cdaae1e4bfb2800ad/torch-2.2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:cf12cdb66c9c940227ad647bc9cf5dba7e8640772ae10dfe7569a0c1e2a28aca", size = 755466713, upload-time = "2024-03-27T21:08:48.868Z" }, { url = "https://files.pythonhosted.org/packages/05/9b/e5c0df26435f3d55b6699e1c61f07652b8c8a3ac5058a75d0e991f92c2b0/torch-2.2.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:89ddac2a8c1fb6569b90890955de0c34e1724f87431cacff4c1979b5f769203c", size = 86515814, upload-time = "2024-03-27T21:09:07.247Z" }, + { url = "https://files.pythonhosted.org/packages/72/ce/beca89dcdcf4323880d3b959ef457a4c61a95483af250e6892fec9174162/torch-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:451331406b760f4b1ab298ddd536486ab3cfb1312614cfe0532133535be60bea", size = 198528804, upload-time = "2024-03-27T21:09:14.691Z" }, { url = "https://files.pythonhosted.org/packages/79/78/29dcab24a344ffd9ee9549ec0ab2c7885c13df61cde4c65836ee275efaeb/torch-2.2.2-cp312-none-macosx_10_9_x86_64.whl", hash = "sha256:eb4d6e9d3663e26cd27dc3ad266b34445a16b54908e74725adb241aa56987533", size = 150797270, upload-time = "2024-03-27T21:08:29.623Z" }, { url = "https://files.pythonhosted.org/packages/4a/0e/e4e033371a7cba9da0db5ccb507a9174e41b9c29189a932d01f2f61ecfc0/torch-2.2.2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:bf9558da7d2bf7463390b3b2a61a6a3dbb0b45b161ee1dd5ec640bf579d479fc", size = 59678388, upload-time = "2024-03-27T21:08:35.869Z" }, ] @@ -3845,19 +4776,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, { url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" }, { url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" }, { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, { url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" }, { url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" }, { url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" }, { url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" }, { url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" }, + { url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" }, { url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" }, { url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" }, { url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" }, { url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" }, + { url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" }, { url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" }, { url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" }, { url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" }, + { url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" }, ] [[package]] @@ -3902,6 +4838,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/87/0c3593552cb0d09ab6271d37fc0e6a9476919d2a975661d709d4b3289fc7/tree_sitter-0.21.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af992dfe08b4fefcfcdb40548d0d26d5d2e0a0f2d833487372f3728cd0772b48", size = 502155, upload-time = "2024-03-26T10:53:04.76Z" }, { url = "https://files.pythonhosted.org/packages/05/92/b2cb22cf52c18fcc95662897f380cf230c443dfc9196b872aad5948b7bb3/tree_sitter-0.21.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c7cbab1dd9765138505c4a55e2aa857575bac4f1f8a8b0457744a4fefa1288e6", size = 486020, upload-time = "2024-03-26T10:53:06.414Z" }, { url = "https://files.pythonhosted.org/packages/4a/ea/69b543538a46d763f3e787234d1617b718ab90f32ffa676ca856f1d9540e/tree_sitter-0.21.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e1e66aeb457d1529370fcb0997ae5584c6879e0e662f1b11b2f295ea57e22f54", size = 496348, upload-time = "2024-03-26T10:53:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/eb/4f/df4ea84476443021707b537217c32147ccccbc3e10c17b216a969991e1b3/tree_sitter-0.21.3-cp312-cp312-win_amd64.whl", hash = "sha256:013c750252dc3bd0e069d82e9658de35ed50eecf31c6586d0de7f942546824c5", size = 109771, upload-time = "2024-03-26T10:53:10.342Z" }, ] [[package]] @@ -3915,6 +4852,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/e7/b7dee7289ff66af83bf4c784847c9ded95276794d8d070495ca4b41d963d/tree_sitter_java-0.21.0-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9c01f48ec3a3d4b55fb2c56b7cad6ebf94dba568d690b45d966731859485dd4", size = 116990, upload-time = "2024-04-07T18:44:00.859Z" }, { url = "https://files.pythonhosted.org/packages/8a/e3/591d561cc52a7b672b3c0836696b97222673ee7b0d2785b5ee26064dce1f/tree_sitter_java-0.21.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d83ca51b26826550051c53468ec74f5b570b4afb4d4520df0aa2a35b52de2289", size = 128126, upload-time = "2024-04-07T18:44:02.731Z" }, { url = "https://files.pythonhosted.org/packages/eb/48/fbf338a749bd6e7e58bf9c30de5d7afb697a8ac5263cb68e79bdf18a6cfa/tree_sitter_java-0.21.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:8afb56c90048272b079235b82fc2a4a69b058b4ace206d9475cbad2eb143f40a", size = 119512, upload-time = "2024-04-07T18:44:04.682Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e2/a84f1b286197645cbedd360971ed0da4ad1ff21be236d927e62b679186de/tree_sitter_java-0.21.0-cp38-abi3-win_amd64.whl", hash = "sha256:6534fac27a93160a2b27a0c77f22a7c91f0aee2dba9d4cdbf835bf509ddfbf4f", size = 74376, upload-time = "2024-04-07T18:44:06.002Z" }, ] [[package]] @@ -3929,6 +4867,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/e3/a9f64c23cd37e88aae0758f31502f57613aa73058fb49c1c6eb9df97d201/tree_sitter_javascript-0.21.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00ff426e9bb552abb49ce3234b083edb1a6aa73bfeea7d9bf0f6bb3d0256c4cd", size = 76624, upload-time = "2024-07-06T00:52:01.315Z" }, { url = "https://files.pythonhosted.org/packages/61/d4/8ed6ef32244dbb652a0e433a6f357afaf91614f69159abb03e4a5f96109a/tree_sitter_javascript-0.21.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c2945bc49985ab396773e2738ae9b1e7d06950d6ad70c535c010fb9b2816a855", size = 73782, upload-time = "2024-07-06T00:52:02.675Z" }, { url = "https://files.pythonhosted.org/packages/ad/a1/57579a8ab9a23ea6204d54958516a0119e168960e1d34cff036b9e762abc/tree_sitter_javascript-0.21.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5fc87d07f4c6a7067d4e0f004956a549c05e21eeced3287f3ea2bdd04a71a97a", size = 73276, upload-time = "2024-07-06T00:52:03.794Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/0681f027261093609cc56e5ca5e3c8e7eeee16e01081e0eee6c8d02fd2ce/tree_sitter_javascript-0.21.4-cp38-abi3-win_amd64.whl", hash = "sha256:2843b0e81564d8176922ef2b40db128a4de026606d6b1d22a06efb8e8a5c01b8", size = 60417, upload-time = "2024-07-06T00:52:04.7Z" }, ] [[package]] @@ -4083,6 +5022,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, @@ -4090,6 +5031,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, @@ -4097,6 +5040,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, @@ -4104,6 +5049,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] @@ -4133,34 +5080,79 @@ sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6 wheels = [ { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, ] [[package]] @@ -4178,36 +5170,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] From 09b80b50df78feaf9184b759cf1b947b47f4060f Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 14:58:00 -0700 Subject: [PATCH 21/45] fix(interrupt): a ^C'd run lands honest artifacts and exits 130 A mid-run Ctrl-C used to finalize the aggregator through the plain ENDED path, shipping a state=complete final_snapshot.json for an aborted run (and losing result_summary.json/events.jsonl to a stray KeyboardInterrupt racing teardown). Now: - BenchmarkSession publishes a SessionEventType.INTERRUPTED marker before its terminal ENDED whenever the run was stopped early; the aggregator latches it and finalizes state=interrupted after the normal sample drain. - One process-level _SigintGovernor (installed once by run_benchmark) owns SIGINT for the whole run: first ^C stops the session gracefully via loop.call_soon_threadsafe, burst re-deliveries within 1s (uv run & co. forward the group signal) are the same keystroke, a later distinct ^C force-quits. No window-scoped install/remove pairs anywhere else. - run_benchmark finalizes artifacts first, then raises KeyboardInterrupt (exit 130); an interrupted report can no longer exit 0 (transport closure included), and the split-brain complete-rewrite guard now also covers user interrupts. - MetricsPipeline.__aexit__ kills service children when an exception unwinds even after drain initiation (no more orphaned aggregator). - The event logger ignores the foreground-group SIGINT like the aggregator, so events.jsonl survives a ^C. New coverage: real-subprocess CLI SIGINT integration test (exit 130, interrupted artifacts, no orphans), session marker + aggregator latch units, finalize rewrite for both abort causes, KeyboardInterrupt->130 in the exit-code map. --- docs/CLI_QUICK_REFERENCE.md | 18 +++ .../services/event_logger/__main__.py | 15 ++ .../services/metrics_aggregator/aggregator.py | 16 +- .../commands/benchmark/execute.py | 139 +++++++++++++--- .../commands/benchmark/pipeline.py | 18 ++- src/inference_endpoint/core/record.py | 6 + .../load_generator/session.py | 6 + tests/integration/commands/test_sigint.py | 150 ++++++++++++++++++ .../metrics_aggregator/test_aggregator.py | 41 +++++ tests/unit/commands/test_benchmark.py | 46 ++++++ tests/unit/commands/test_util_commands.py | 2 + .../unit/load_generator/test_async_session.py | 49 ++++++ 12 files changed, 481 insertions(+), 25 deletions(-) create mode 100644 tests/integration/commands/test_sigint.py diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 803f1cce5..75d35c326 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -185,6 +185,24 @@ How the knobs compose: 4. **`timeouts.run_timeout_s` is the only total-wall-time bound** (setup, every phase, every drain) — firing aborts the run, marks the report INTERRUPTED, and exits non-zero. +### Ctrl-C (SIGINT) + +One handler owns SIGINT for the whole run: + +- **First ^C**: graceful abort. The session stops issuing, in-flight drains are + released, buffered samples still reach the metrics aggregator, and the + artifacts land honest — `final_snapshot.json` `state: interrupted`, + `result_summary.json` `complete: false`, `events.jsonl` flushed. Exit 130. +- **^C again (after ~1 s)**: force quit — teardown is abandoned, service + children are killed, exit 130 with whatever artifacts were already written. + (Repeat deliveries within ~1 s are treated as the same keystroke: process + runners like `uv run` forward the terminal's group SIGINT to their child, + which already received it.) +- **^C during setup** (dataset/tokenizer load, before services): immediate + abort, exit 130, no artifacts. + +A ^C'd run never exits 0 and never writes `complete: true` artifacts. + ## Environment Variables **In YAML files** — use `${VAR}` or `${VAR:-default}` syntax: diff --git a/src/inference_endpoint/async_utils/services/event_logger/__main__.py b/src/inference_endpoint/async_utils/services/event_logger/__main__.py index f57d51a0e..5539058d8 100644 --- a/src/inference_endpoint/async_utils/services/event_logger/__main__.py +++ b/src/inference_endpoint/async_utils/services/event_logger/__main__.py @@ -24,7 +24,9 @@ import argparse import asyncio import importlib.util +import logging import os +import signal from pathlib import Path from inference_endpoint.async_utils.loop_manager import LoopManager @@ -190,6 +192,19 @@ async def main() -> None: service.start() + # No-op SIGINT handler, mirroring the metrics aggregator: on an + # interactive ^C the OS delivers SIGINT to the whole foreground + # process group. The default KeyboardInterrupt would kill this + # child mid-run and lose every buffered (unflushed) event record; + # the parent's ENDED event is the authoritative shutdown signal. + loop.add_signal_handler( + signal.SIGINT, + lambda: logging.getLogger(__name__).info( + "event logger received SIGINT — ignoring " + "(parent's ENDED path is authoritative)" + ), + ) + if args.readiness_path: await send_ready_signal(zmq_ctx, args.readiness_path, args.readiness_id) diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/aggregator.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/aggregator.py index cd34a88f2..fb0001977 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/aggregator.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/aggregator.py @@ -161,6 +161,10 @@ def __init__( self._streaming = streaming self._shutdown_event = shutdown_event self._shutdown_received = False + # Latched by SessionEventType.INTERRUPTED (published by the session + # right before ENDED on an aborted run) so the ENDED-driven finalize + # below tags the snapshot state=interrupted, not COMPLETE. + self._interrupted = False self._drain_timeout_s = drain_timeout_s self._session_start_ns: int | None = None @@ -318,6 +322,12 @@ async def process(self, records: list[EventRecord]) -> None: logger.info("ENDED event received, shutting down aggregator") self._shutdown_received = True saw_shutdown = True + elif ev == SessionEventType.INTERRUPTED: + logger.info( + "INTERRUPTED marker received — final snapshot will be " + "tagged state=interrupted" + ) + self._interrupted = True else: if ev == SessionEventType.STARTED: if self._session_start_ns is not None: @@ -460,7 +470,11 @@ async def process(self, records: list[EventRecord]) -> None: MetricCounterKey.LEGACY_LOADGEN_WINDOW_DURATION_NS.value, table.total_loadgen_window_ns, ) - await self._publisher.publish_final(registry, n_pending_tasks=n_pending) + await self._publisher.publish_final( + registry, + n_pending_tasks=n_pending, + interrupted=self._interrupted, + ) finally: # The aggregator MUST close the publisher and signal shutdown even # if the drain/publish above failed — otherwise main()'s diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 0728306d6..e1ef64826 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -168,6 +168,64 @@ class BenchmarkResult: # report.txt and a sibling profiling.json by finalize_benchmark. profiling: dict[str, Any] | None = None run_timed_out: bool = False + # True when the run was stopped by the user's SIGINT (first ^C). Artifacts + # are finalized as interrupted first; run_benchmark then raises + # KeyboardInterrupt so main.py maps the run to exit 130. + user_interrupted: bool = False + + +class _SigintGovernor: + """The run's single SIGINT policy — installed ONCE by ``run_benchmark``. + + One ``signal.signal`` handler covers the entire run (setup, session, + metrics drain, finalize) instead of window-scoped install/remove pairs, + whose gaps are exactly where a ^C used to slip through as a raw + KeyboardInterrupt and abort teardown half-way. + + Semantics: + - ^C with no live session (sync setup): nothing to stop gracefully — + raise KeyboardInterrupt immediately (default behavior, exit 130). + - First ^C with a session bound: graceful — ``session.stop()``; the + stopped run publishes INTERRUPTED+ENDED, services drain, artifacts land + as state=interrupted, then ``run_benchmark`` raises for exit 130. + - Repeat deliveries inside the burst window are the SAME keystroke: + process-runner wrappers sharing the foreground group (``uv run``, + ``npm exec``, ...) receive the group SIGINT and forward it to their + child, which already got it directly. Never an escalation. + - A later distinct ^C: force — raise KeyboardInterrupt, abandoning the + graceful teardown (pipeline ``__aexit__`` still kills service children). + """ + + _BURST_WINDOW_S = 1.0 + + def __init__(self) -> None: + self.interrupted = False + self._last_at = 0.0 + self._session: BenchmarkSession | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + def bind_session( + self, session: BenchmarkSession, loop: asyncio.AbstractEventLoop + ) -> None: + self._session = session + self._loop = loop + + def __call__(self, signum: int, frame: object) -> None: + now = time.monotonic() + if self.interrupted: + if now - self._last_at < self._BURST_WINDOW_S: + return + raise KeyboardInterrupt + self.interrupted = True + self._last_at = now + if self._session is None or self._loop is None: + raise KeyboardInterrupt + logger.warning("SIGINT received: stopping benchmark (^C again to force)") + # A signal handler runs at an arbitrary bytecode boundary — possibly + # mid-event-loop-iteration. Don't mutate asyncio state (Event.set, + # Task.cancel) from here; hand session.stop to the loop, the one + # asyncio entry point documented as signal-handler safe. + self._loop.call_soon_threadsafe(self._session.stop) @dataclass @@ -771,6 +829,7 @@ async def _run_benchmark_async( loop: asyncio.AbstractEventLoop, *, deadline: float | None = None, + sigint: _SigintGovernor | None = None, ) -> BenchmarkResult: """Run async benchmark session.""" config = ctx.config @@ -889,7 +948,8 @@ def _on_phase_start(phase: PhaseConfig) -> None: # issued, so the server is armed when traffic begins. profiler.start() - loop.add_signal_handler(signal.SIGINT, session.stop) + if sigint is not None: + sigint.bind_session(session, loop) try: # A pre-session fire already stopped the session inside # bind_session: zero samples issue, STARTED/ENDED still @@ -929,7 +989,10 @@ def _on_phase_start(phase: PhaseConfig) -> None: finally: _timeout_done = True perf_timeout.cancel() - loop.remove_signal_handler(signal.SIGINT) + # NOTE: no SIGINT bookkeeping here — the process-level + # _SigintGovernor (installed once by run_benchmark) covers + # the metrics drain below with the same graceful/debounce + # semantics. # Fire /stop_profile for URLs whose /start_profile succeeded. # Unifies the clean phase-end path and the abort path — both # reach this block. A watchdog abort counts as an abort even @@ -1009,11 +1072,15 @@ def _on_phase_start(phase: PhaseConfig) -> None: tmpfs_dir=tmpfs_dir, profiling=profiler.payload(), run_timed_out=watchdog.fired, + user_interrupted=sigint.interrupted if sigint is not None else False, ) def run_benchmark_async( - ctx: BenchmarkContext, *, deadline: float | None = None + ctx: BenchmarkContext, + *, + deadline: float | None = None, + sigint: _SigintGovernor | None = None, ) -> BenchmarkResult: """Run async benchmark. Sync entry point — drives the event loop. @@ -1027,7 +1094,9 @@ def run_benchmark_async( ): deadline = time.monotonic() + run_timeout_s loop = LoopManager().default_loop - return loop.run_until_complete(_run_benchmark_async(ctx, loop, deadline=deadline)) + return loop.run_until_complete( + _run_benchmark_async(ctx, loop, deadline=deadline, sigint=sigint) + ) def _write_scoring_artifacts( @@ -1149,13 +1218,16 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: result = bench.session collector = bench.collector report = bench.report - if report is not None and bench.run_timed_out and report.complete: + aborted = bench.run_timed_out or bench.user_interrupted + if report is not None and aborted and report.complete: # Split-brain guard: the aggregator may have finalized COMPLETE before - # the watchdog's SIGTERM landed. A timed-out run must never publish + # the watchdog's SIGTERM landed — or a ^C arrived after the session + # already published its terminal ENDED (drain window), so the + # INTERRUPTED marker never went out. An aborted run must never publish # complete:true artifacts, so force both fields honest before writing — - # state stays what the SIGTERM path would have recorded, and consumers + # state stays what the abort path would have recorded, and consumers # keying on state=="complete" and not complete (the drain-timeout - # signature) don't misattribute a watchdog abort to a slow drain. + # signature) don't misattribute an abort to a slow drain. report = msgspec.structs.replace(report, complete=False, state="interrupted") # Write scoring artifacts + copy event log from tmpfs to disk (scorers read @@ -1170,13 +1242,14 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # then the exception propagates as before. accuracy_scores: list[dict[str, Any]] = [] try: - if bench.run_timed_out: + if aborted: # Phases may never have started (scorer init KeyErrors on missing # sample maps) and partial phases would yield misleading subset # scores; the scoring artifacts above are still on disk for # inspection. logger.warning( - "Run timeout fired — skipping accuracy scoring on partial data" + "Run aborted (%s) — skipping accuracy scoring on partial data", + "run timeout" if bench.run_timed_out else "user interrupt", ) else: accuracy_scores = score_accuracy(ctx, result) @@ -1239,22 +1312,50 @@ def run_benchmark( deadline: float | None = None if (run_timeout_s := config.settings.timeouts.run_timeout_s) is not None: deadline = time.monotonic() + run_timeout_s - ctx = setup_benchmark(config, test_mode) - if deadline is not None and time.monotonic() >= deadline: - # Setup alone consumed the budget: fail before any services start. - raise ExecutionError( - f"Run timeout ({run_timeout_s}s) reached during setup; " - "no services were started" - ) + # The run's ONE SIGINT handler, installed here and restored in the finally + # — no window-scoped install/remove pairs anywhere else in the run (their + # gaps are where a ^C used to abort teardown as a raw KeyboardInterrupt). + # No session bound yet, so a ^C during setup keeps default abort behavior. + sigint = _SigintGovernor() + prev_sigint = None + try: + prev_sigint = signal.signal(signal.SIGINT, sigint) + except ValueError: + pass # not the main thread (embedded use): governor stays passive bench: BenchmarkResult | None = None try: - bench = run_benchmark_async(ctx, deadline=deadline) + ctx = setup_benchmark(config, test_mode) + if deadline is not None and time.monotonic() >= deadline: + # Setup alone consumed the budget: fail before any services start. + raise ExecutionError( + f"Run timeout ({run_timeout_s}s) reached during setup; " + "no services were started" + ) + bench = run_benchmark_async(ctx, deadline=deadline, sigint=sigint) finalize_benchmark(ctx, bench) + if bench.user_interrupted or sigint.interrupted: + # Artifacts are finalized (state=interrupted, complete:false) + # ABOVE — only now surface the ^C so main.py exits 130. Checked + # before run_timed_out: if the user interrupted a run whose + # watchdog also fired, the user's abort is the truthful cause. + raise KeyboardInterrupt if bench.run_timed_out: raise ExecutionError( f"Run timeout ({run_timeout_s}s) reached; run aborted and " "report marked INTERRUPTED" ) + if ( + bench.report is not None + and bench.report.state == "interrupted" + and not bench.run_timed_out + ): + # The session was stopped without a user ^C or a watchdog fire: + # transport closure or an external stop. Never exit 0 on + # interrupted artifacts. + raise ExecutionError( + "Session aborted before completion (transport closure or " + "external stop); report marked INTERRUPTED" + ) if ( bench.report is not None and bench.report.state == "complete" @@ -1277,6 +1378,8 @@ def run_benchmark( logger.warning("Benchmark interrupted by user") raise finally: + if prev_sigint is not None: + signal.signal(signal.SIGINT, prev_sigint) if bench: if bench.tmpfs_dir.exists(): try: diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index ffb566d2e..124cd231c 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -235,19 +235,25 @@ async def __aexit__( exc: BaseException | None, tb: TracebackType | None, ) -> bool | None: - """Release the pipeline. Kill the services iff the run never drained. + """Release the pipeline. Kill the services unless the run drained cleanly. ``drain_and_build_report`` nulls ``self.publisher`` at drain initiation; a still-set publisher means the drain was never initiated (setup / connect / - session error before it), - so the service subprocesses are killed rather than left on the aggregator's - unlimited drain-timeout. The ``ExitStack`` then releases publisher, subscriber - and the ZMQ scope — running every step even under ``BaseException``. + session error before it), so the service subprocesses are killed rather + than left on the aggregator's unlimited drain-timeout. An in-flight + exception forces the kill even after drain initiation: a KeyboardInterrupt + (or teardown error) that aborts the drain wait must not leave the + aggregator/event-logger children orphaned — ``terminate_all`` is a no-op + for processes that already exited. The ``ExitStack`` then releases + publisher, subscriber and the ZMQ scope — running every step even under + ``BaseException``. """ if self._stack is None: return None stack, self._stack = self._stack, None - if self.publisher is not None and self._launcher is not None: + if self._launcher is not None and ( + self.publisher is not None or exc_type is not None + ): # Register this last so it runs first. ExitStack still executes the # publisher/subscriber/ZMQ callbacks if terminate_all raises BaseException # (for example, a second Ctrl-C during teardown). diff --git a/src/inference_endpoint/core/record.py b/src/inference_endpoint/core/record.py index ccdb745e4..30ead8f81 100644 --- a/src/inference_endpoint/core/record.py +++ b/src/inference_endpoint/core/record.py @@ -124,6 +124,12 @@ class SessionEventType(EventType): STARTED = "started" ENDED = "ended" + # Abort marker published by BenchmarkSession just before ENDED whenever + # the session was stopped early (Ctrl-C, transport closure, run watchdog). + # NOT a terminal event: consumers still shut down on ENDED; the metrics + # aggregator latches it so its ENDED-driven finalize writes + # state=interrupted instead of a lying COMPLETE snapshot. + INTERRUPTED = "interrupted" STOP_LOADGEN = "stop_loadgen" START_PERFORMANCE_TRACKING = "start_performance_tracking" STOP_PERFORMANCE_TRACKING = "stop_performance_tracking" diff --git a/src/inference_endpoint/load_generator/session.py b/src/inference_endpoint/load_generator/session.py index 371beb019..5f99e39ad 100644 --- a/src/inference_endpoint/load_generator/session.py +++ b/src/inference_endpoint/load_generator/session.py @@ -454,6 +454,12 @@ async def run( await self._recv_task except asyncio.CancelledError: pass + if self._stop_requested: + # Aborted run (Ctrl-C, transport closure, run watchdog): mark + # it BEFORE the terminal ENDED so the aggregator's ENDED-driven + # finalize — which still drains buffered samples first — writes + # state=interrupted rather than a normal COMPLETE snapshot. + self._publish_session_event(SessionEventType.INTERRUPTED) self._publish_session_event(SessionEventType.ENDED) return SessionResult( diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py new file mode 100644 index 000000000..9969b57f2 --- /dev/null +++ b/tests/integration/commands/test_sigint.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Whole-process Ctrl-C integration test. + +The one interruption path no unit test can compose: a real +``inference-endpoint`` subprocess in its own process group receives SIGINT +(exactly what a terminal ^C delivers to the foreground group — parent and +service children alike) mid-run. The contract: + +- exit code 130 (user abort, distinct from failure exit codes 1-4); +- artifacts are honest: ``final_snapshot.json`` ``state=interrupted`` (the + session's INTERRUPTED marker drives the aggregator's ENDED finalize) and + ``result_summary.json`` ``complete: false``; +- ``events.jsonl`` survives — the event logger ignores the group SIGINT and + flushes on the session's terminal ENDED; +- no service child outlives the run; +- teardown is prompt, not a hang on an unbounded drain. +""" + +import json +import os +import shutil +import signal +import subprocess +import time +from pathlib import Path + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parents[2] +_CHAR_TOKENIZER_DIR = _TESTS_DIR / "assets/tokenizers/char" +_DS_DATASET = _TESTS_DIR / "assets/datasets/ds_samples.jsonl" + + +def _write_config(report_dir: Path, endpoint_url: str, config_path: Path) -> None: + """~120 s workload (600 samples @ 5 QPS): only the ^C can end the run.""" + config_path.write_text( + f""" +type: online +endpoint_config: + endpoints: ["{endpoint_url}"] +model_params: + name: "{_CHAR_TOKENIZER_DIR}" + streaming: "off" +datasets: + - path: "{_DS_DATASET}" + type: performance +report_dir: {report_dir} +settings: + load_pattern: + type: poisson + target_qps: 5 + client: + num_workers: 1 + warmup_connections: 0 + max_connections: 10 + runtime: + n_samples_to_issue: 600 + warmup: + enabled: false +""" + ) + + +def _procs_referencing(needle: str) -> list[str]: + """Cmdlines of live processes whose argv mentions ``needle`` (Linux).""" + hits = [] + for pid_dir in Path("/proc").iterdir(): + if not pid_dir.name.isdigit(): + continue + try: + cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\0", b" ") + except OSError: + continue # process exited mid-scan + if needle.encode() in cmdline: + hits.append(cmdline.decode(errors="replace")) + return hits + + +@pytest.mark.integration +def test_sigint_mid_run_exits_130_with_interrupted_artifacts( + mock_http_echo_server, tmp_path +): + cli = shutil.which("inference-endpoint") + assert cli is not None, "console script must be installed in the test venv" + + report_dir = tmp_path / "report" + config_path = tmp_path / "bench.yaml" + _write_config(report_dir, mock_http_echo_server.url, config_path) + + proc = subprocess.Popen( + [cli, "benchmark", "from-config", "-c", str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # own process group, like a foreground job + ) + try: + # The aggregator touches metrics/.ready once its signal handlers are + # registered; the session starts issuing right after service readiness. + ready = report_dir / "metrics" / ".ready" + deadline = time.monotonic() + 60.0 + while not ready.exists(): + assert proc.poll() is None, "benchmark died before services came up" + assert time.monotonic() < deadline, "services never became ready" + time.sleep(0.1) + time.sleep(3.0) # comfortably inside the ~120 s performance phase + + os.killpg(proc.pid, signal.SIGINT) + rc = proc.wait(timeout=60.0) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + + assert rc == 130, f"user abort must exit 130, got {rc}" + + snapshot = json.loads((report_dir / "metrics" / "final_snapshot.json").read_text()) + assert snapshot["state"] == "interrupted" + + summary = json.loads( + (report_dir / "performance" / "result_summary.json").read_text() + ) + assert summary["complete"] is False + + # The event logger must survive the group SIGINT and flush on ENDED. + assert ( + report_dir / "events.jsonl" + ).exists(), "events.jsonl missing — event logger died on ^C instead of flushing" + + # No aggregator/event-logger child may outlive the run. + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + leftovers = _procs_referencing(str(report_dir)) + if not leftovers: + break + time.sleep(0.2) + assert not leftovers, f"service children outlived the run: {leftovers}" diff --git a/tests/unit/async_utils/services/metrics_aggregator/test_aggregator.py b/tests/unit/async_utils/services/metrics_aggregator/test_aggregator.py index f91daa567..4928bd4e9 100644 --- a/tests/unit/async_utils/services/metrics_aggregator/test_aggregator.py +++ b/tests/unit/async_utils/services/metrics_aggregator/test_aggregator.py @@ -540,6 +540,47 @@ async def test_session_ended_calls_publish_final(self, tmp_path): finally: agg.close() + @pytest.mark.asyncio + async def test_interrupted_marker_tags_final_snapshot(self, tmp_path): + """The session's INTERRUPTED marker (published just before ENDED on an + aborted run) must flow through to ``publish_final(interrupted=True)`` + so the ENDED-driven finalize writes state=interrupted — a ^C'd run + must never produce a COMPLETE final snapshot.""" + loop = asyncio.get_event_loop() + with ManagedZMQContext.scoped(socket_dir=str(tmp_path)) as ctx: + agg, _, publisher = make_aggregator(ctx, loop, "agg_interrupted_marker") + try: + await agg.process( + [ + session_event(SessionEventType.STARTED, ts=0), + session_event(SessionEventType.INTERRUPTED, ts=50), + session_event(SessionEventType.ENDED, ts=100), + ] + ) + publisher.publish_final.assert_awaited_once() + assert publisher.publish_final.await_args.kwargs["interrupted"] is True + finally: + agg.close() + + @pytest.mark.asyncio + async def test_clean_ended_finalizes_uninterrupted(self, tmp_path): + """Without the marker, the ENDED-driven finalize stays a normal + completion (interrupted=False) — the marker must be opt-in.""" + loop = asyncio.get_event_loop() + with ManagedZMQContext.scoped(socket_dir=str(tmp_path)) as ctx: + agg, _, publisher = make_aggregator(ctx, loop, "agg_clean_uninterrupted") + try: + await agg.process( + [ + session_event(SessionEventType.STARTED, ts=0), + session_event(SessionEventType.ENDED, ts=100), + ] + ) + publisher.publish_final.assert_awaited_once() + assert publisher.publish_final.await_args.kwargs["interrupted"] is False + finally: + agg.close() + @pytest.mark.asyncio async def test_events_after_ended_are_dropped(self, tmp_path): loop = asyncio.get_event_loop() diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 90c8305da..3a44d5df0 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -105,6 +105,7 @@ SessionResult, ) from inference_endpoint.metrics.metric import Throughput +from inference_endpoint.metrics.report import Report from pydantic import ValidationError TEMPLATE_DIR = ( @@ -2249,6 +2250,51 @@ def test_skip_endpoint_phase_scorer_reports_external_sample_count( assert results["accuracy_scores"][0]["unit_samples"] == expected assert results["accuracy_scores"][0]["total_samples"] == expected + @pytest.mark.unit + @pytest.mark.parametrize("abort_field", ["run_timed_out", "user_interrupted"]) + def test_aborted_run_never_writes_complete_artifacts(self, tmp_path, abort_field): + """Split-brain guard: the aggregator finalized a COMPLETE snapshot but + the run was aborted — watchdog fired late, or a ^C landed after the + session's terminal ENDED (drain window), so the INTERRUPTED marker + never went out. result_summary.json must still land complete:false / + state:interrupted; an aborted run must never ship complete artifacts.""" + config = OfflineConfig(**_OFFLINE_KWARGS) + ctx = _make_benchmark_context(config=config, report_dir=tmp_path) + report = Report.from_snapshot( + { + "counter": 1, + "timestamp_ns": 12345, + "state": "complete", + "n_pending_tasks": 0, + "metrics": [ + { + "type": "counter", + "name": "tracked_samples_completed", + "value": 3, + }, + {"type": "counter", "name": "tracked_samples_issued", "value": 3}, + { + "type": "counter", + "name": "tracked_duration_ns", + "value": 1_000_000_000, + }, + {"type": "counter", "name": "tracked_samples_failed", "value": 0}, + ], + } + ) + assert report.complete is True, "precondition: aggregator said COMPLETE" + bench = _make_benchmark_result(tmp_path) + bench.report = report + setattr(bench, abort_field, True) + + finalize_benchmark(ctx, bench) + + summary = json.loads( + (tmp_path / "performance" / "result_summary.json").read_text() + ) + assert summary["complete"] is False + assert summary["state"] == "interrupted" + class TestScorerMethodSync: """Ensure ScorerMethod enum stays in sync with the scorer registry.""" diff --git a/tests/unit/commands/test_util_commands.py b/tests/unit/commands/test_util_commands.py index 96c897b44..2bda83c3d 100644 --- a/tests/unit/commands/test_util_commands.py +++ b/tests/unit/commands/test_util_commands.py @@ -200,6 +200,8 @@ class TestMainRunExceptionHandling: (CLIError("cli error"), 1), (NotImplementedError("not impl"), 1), (RuntimeError("unexpected"), 1), + # Ctrl-C: scripts distinguish user-abort (130) from failure (1). + (KeyboardInterrupt(), 130), ], ) def test_exception_exit_codes(self, exc, code): diff --git a/tests/unit/load_generator/test_async_session.py b/tests/unit/load_generator/test_async_session.py index cfdf697dd..a0fdbef08 100644 --- a/tests/unit/load_generator/test_async_session.py +++ b/tests/unit/load_generator/test_async_session.py @@ -533,6 +533,55 @@ async def test_stop_terminates_early(self): # Should have stopped early, not issued all 100k assert result.perf_results[0].issued_count < 100_000 + @pytest.mark.asyncio + async def test_stopped_session_publishes_interrupted_marker_before_ended(self): + """A stopped run (Ctrl-C, watchdog, transport closure) must announce + the abort on the events channel: INTERRUPTED goes out right before the + terminal ENDED so the metrics aggregator finalizes state=interrupted + instead of a normal COMPLETE snapshot.""" + loop = asyncio.get_running_loop() + issuer = FakeIssuer() + issuer._loop = loop + publisher = FakePublisher() + + session = BenchmarkSession(issuer, publisher, loop) + loop.call_later(0.05, session.stop) + + phases = [ + PhaseConfig( + "perf", + _make_settings(n_samples=100_000, max_duration_ms=10_000), + FakeDataset(100), + ), + ] + await session.run(phases) + + interrupted = publisher.events_of_type(SessionEventType.INTERRUPTED) + ended = publisher.events_of_type(SessionEventType.ENDED) + assert len(interrupted) == 1 + assert len(ended) == 1 + assert publisher.events.index(interrupted[0]) < publisher.events.index( + ended[0] + ), "INTERRUPTED must precede the terminal ENDED" + + @pytest.mark.asyncio + async def test_clean_session_publishes_no_interrupted_marker(self): + """A run that completes normally must NOT carry the abort marker — + otherwise every clean run would finalize as interrupted.""" + loop = asyncio.get_running_loop() + issuer = FakeIssuer() + issuer._loop = loop + publisher = FakePublisher() + + session = BenchmarkSession(issuer, publisher, loop) + phases = [ + PhaseConfig("perf", _make_settings(n_samples=5), FakeDataset(5)), + ] + await session.run(phases) + + assert publisher.events_of_type(SessionEventType.INTERRUPTED) == [] + assert len(publisher.events_of_type(SessionEventType.ENDED)) == 1 + @pytest.mark.asyncio async def test_stop_current_phase_advances_to_accuracy(self): """A perf-phase timeout must end only that phase, not skip accuracy. From 359e9a66fb98b72d951feb3a062ae25c5e963b25 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 17:01:02 -0700 Subject: [PATCH 22/45] refactor(interrupt): SigintGovernor lives in watchdog.py; repeat ^C is a no-op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abort policies now share one module (PerfPhaseTimeout, RunWatchdog, SigintGovernor). Force-quit escalation and its burst-window heuristic are deleted: one keystroke can be delivered repeatedly (uv run & co. forward the group SIGINT), so a 'second' ^C cannot be told apart from the first — repeats are no-ops. A wedged teardown is bounded by run_timeout_s or killed externally. --- docs/CLI_QUICK_REFERENCE.md | 9 ++- .../commands/benchmark/execute.py | 66 ++----------------- .../commands/benchmark/watchdog.py | 55 +++++++++++++++- 3 files changed, 62 insertions(+), 68 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 75d35c326..97dbef2cd 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -193,11 +193,10 @@ One handler owns SIGINT for the whole run: released, buffered samples still reach the metrics aggregator, and the artifacts land honest — `final_snapshot.json` `state: interrupted`, `result_summary.json` `complete: false`, `events.jsonl` flushed. Exit 130. -- **^C again (after ~1 s)**: force quit — teardown is abandoned, service - children are killed, exit 130 with whatever artifacts were already written. - (Repeat deliveries within ~1 s are treated as the same keystroke: process - runners like `uv run` forward the terminal's group SIGINT to their child, - which already received it.) +- **Further ^C**: no-op. One keystroke can be delivered repeatedly (process + runners like `uv run` forward the terminal's group SIGINT to a child that + already received it), so repeats are indistinguishable from the first. A + wedged teardown is bounded by `run_timeout_s` or killed externally. - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index e1ef64826..191948ba2 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -65,6 +65,7 @@ from inference_endpoint.commands.benchmark.watchdog import ( PerfPhaseTimeout, RunWatchdog, + SigintGovernor, ) from inference_endpoint.compliance import AuditRunSpec from inference_endpoint.config.runtime_settings import RuntimeSettings @@ -174,60 +175,6 @@ class BenchmarkResult: user_interrupted: bool = False -class _SigintGovernor: - """The run's single SIGINT policy — installed ONCE by ``run_benchmark``. - - One ``signal.signal`` handler covers the entire run (setup, session, - metrics drain, finalize) instead of window-scoped install/remove pairs, - whose gaps are exactly where a ^C used to slip through as a raw - KeyboardInterrupt and abort teardown half-way. - - Semantics: - - ^C with no live session (sync setup): nothing to stop gracefully — - raise KeyboardInterrupt immediately (default behavior, exit 130). - - First ^C with a session bound: graceful — ``session.stop()``; the - stopped run publishes INTERRUPTED+ENDED, services drain, artifacts land - as state=interrupted, then ``run_benchmark`` raises for exit 130. - - Repeat deliveries inside the burst window are the SAME keystroke: - process-runner wrappers sharing the foreground group (``uv run``, - ``npm exec``, ...) receive the group SIGINT and forward it to their - child, which already got it directly. Never an escalation. - - A later distinct ^C: force — raise KeyboardInterrupt, abandoning the - graceful teardown (pipeline ``__aexit__`` still kills service children). - """ - - _BURST_WINDOW_S = 1.0 - - def __init__(self) -> None: - self.interrupted = False - self._last_at = 0.0 - self._session: BenchmarkSession | None = None - self._loop: asyncio.AbstractEventLoop | None = None - - def bind_session( - self, session: BenchmarkSession, loop: asyncio.AbstractEventLoop - ) -> None: - self._session = session - self._loop = loop - - def __call__(self, signum: int, frame: object) -> None: - now = time.monotonic() - if self.interrupted: - if now - self._last_at < self._BURST_WINDOW_S: - return - raise KeyboardInterrupt - self.interrupted = True - self._last_at = now - if self._session is None or self._loop is None: - raise KeyboardInterrupt - logger.warning("SIGINT received: stopping benchmark (^C again to force)") - # A signal handler runs at an arbitrary bytecode boundary — possibly - # mid-event-loop-iteration. Don't mutate asyncio state (Event.set, - # Task.cancel) from here; hand session.stop to the loop, the one - # asyncio entry point documented as signal-handler safe. - self._loop.call_soon_threadsafe(self._session.stop) - - @dataclass class BenchmarkContext: """All state needed to run a benchmark, created by setup_benchmark. @@ -829,7 +776,7 @@ async def _run_benchmark_async( loop: asyncio.AbstractEventLoop, *, deadline: float | None = None, - sigint: _SigintGovernor | None = None, + sigint: SigintGovernor | None = None, ) -> BenchmarkResult: """Run async benchmark session.""" config = ctx.config @@ -990,9 +937,8 @@ def _on_phase_start(phase: PhaseConfig) -> None: _timeout_done = True perf_timeout.cancel() # NOTE: no SIGINT bookkeeping here — the process-level - # _SigintGovernor (installed once by run_benchmark) covers - # the metrics drain below with the same graceful/debounce - # semantics. + # SigintGovernor (installed once by run_benchmark) covers + # the metrics drain below too. # Fire /stop_profile for URLs whose /start_profile succeeded. # Unifies the clean phase-end path and the abort path — both # reach this block. A watchdog abort counts as an abort even @@ -1080,7 +1026,7 @@ def run_benchmark_async( ctx: BenchmarkContext, *, deadline: float | None = None, - sigint: _SigintGovernor | None = None, + sigint: SigintGovernor | None = None, ) -> BenchmarkResult: """Run async benchmark. Sync entry point — drives the event loop. @@ -1316,7 +1262,7 @@ def run_benchmark( # — no window-scoped install/remove pairs anywhere else in the run (their # gaps are where a ^C used to abort teardown as a raw KeyboardInterrupt). # No session bound yet, so a ^C during setup keeps default abort behavior. - sigint = _SigintGovernor() + sigint = SigintGovernor() prev_sigint = None try: prev_sigint = signal.signal(signal.SIGINT, sigint) diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index 9b2f7ecc8..a54b3aac6 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -13,11 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run-scoped deadline timers for the benchmark orchestrator. +"""Run-scoped abort machinery for the benchmark orchestrator. ``PerfPhaseTimeout`` bounds the PERFORMANCE phase (``runtime.max_duration_ms``); -``RunWatchdog`` is the whole-run deadline (``settings.timeouts.run_timeout_s``). -Both are event-loop timers owned by ``commands/benchmark/execute.py``. +``RunWatchdog`` is the whole-run deadline (``settings.timeouts.run_timeout_s``); +``SigintGovernor`` is the run's one Ctrl-C policy. All owned by +``commands/benchmark/execute.py``. """ from __future__ import annotations @@ -36,6 +37,54 @@ logger = logging.getLogger(__name__) +class SigintGovernor: + """The run's single SIGINT policy — installed ONCE by ``run_benchmark``. + + One ``signal.signal`` handler covers the entire run (setup, session, + metrics drain, finalize) instead of window-scoped install/remove pairs, + whose gaps are exactly where a ^C used to slip through as a raw + KeyboardInterrupt and abort teardown half-way. + + Semantics: + - ^C with no live session (sync setup): nothing to stop gracefully — + raise KeyboardInterrupt immediately (default behavior, exit 130). + - First ^C with a session bound: graceful — ``session.stop()``; the + stopped run publishes INTERRUPTED+ENDED, services drain, artifacts land + as state=interrupted, then ``run_benchmark`` raises for exit 130. + - Every later ^C is a no-op: one keystroke can be DELIVERED repeatedly + (process-runner wrappers like ``uv run`` forward the terminal's group + SIGINT to a child that already got it directly), so "another ^C" + cannot be told apart from the same one. A wedged teardown is bounded + by ``run_timeout_s`` or killed externally. + """ + + def __init__(self) -> None: + self.interrupted = False + self._session: BenchmarkSession | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + def bind_session( + self, session: BenchmarkSession, loop: asyncio.AbstractEventLoop + ) -> None: + self._session = session + self._loop = loop + + def __call__(self, signum: int, frame: object) -> None: + if self.interrupted: + # ponytail: repeat ^C is a no-op; add a distinct-keystroke + # force-quit only if a real wedged-teardown report demands it. + return + self.interrupted = True + if self._session is None or self._loop is None: + raise KeyboardInterrupt + logger.warning("SIGINT received: stopping benchmark gracefully") + # A signal handler runs at an arbitrary bytecode boundary — possibly + # mid-event-loop-iteration. Don't mutate asyncio state (Event.set, + # Task.cancel) from here; hand session.stop to the loop, the one + # asyncio entry point documented as signal-handler safe. + self._loop.call_soon_threadsafe(self._session.stop) + + class PerfPhaseTimeout: """Session-stop timer that bounds the PERFORMANCE phase only. From 3dee0ccba158a1a322ab88c876ae96c2ae3e1831 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 17:27:21 -0700 Subject: [PATCH 23/45] feat(config)!: drop the --duration alias; --runtime.min-duration-ms is the one spelling Reviewers flagged the duplicate spellings (--duration / --min-duration-ms) as confusing next to --timeout. One flag per meaning now: the watchdog is --timeout (settings.timeouts.run_timeout_s), workload sizing is --runtime.min-duration-ms only. Help text states the poisson-only constraint and the precedence (--num-samples wins; both unset = one dataset pass) explicitly. --- docs/CLI_QUICK_REFERENCE.md | 2 +- docs/config/DESIGN.md | 22 +++++++++---------- src/inference_endpoint/config/schema.py | 20 ++++++++++------- .../templates/concurrency_template_full.yaml | 2 +- .../templates/offline_template_full.yaml | 2 +- .../templates/online_template_full.yaml | 2 +- tests/performance/commands/test_e2e_perf.py | 8 +++---- tests/unit/config/test_schema.py | 2 +- 8 files changed, 32 insertions(+), 28 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 97dbef2cd..773ca200b 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -161,7 +161,7 @@ run_benchmark ── run_timeout_s deadline captured here ─────── | YAML path | CLI flag | Semantics | | ----------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.min_duration_ms` | `--duration` | Sizes the run by time: issue `target_qps` × duration samples (poisson only — requires explicit `target_qps`; ms, or suffix: `600s`, `10m`); None = issue the dataset once | +| `settings.runtime.min_duration_ms` | `--runtime.min-duration-ms` | Poisson only (requires explicit `target_qps`). Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`). Explicit `--num-samples` wins; both unset = issue the dataset once | | `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | | `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | | `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index 6e9639abd..8c65a33af 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -58,17 +58,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | -| `min_duration_ms` | `int \| None` | `--duration` / `runtime.min_duration_ms` (None = no duration target); a ruleset may override once ruleset integration lands | -| `max_duration_ms` | `int \| None` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | +| `min_duration_ms` | `int \| None` | `--runtime.min-duration-ms` / `runtime.min_duration_ms` (poisson only; None = no duration target); a ruleset may override once ruleset integration lands | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 88f0645cd..f8956d2a1 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -610,20 +610,23 @@ class RuntimeConfig(BaseModel): min_duration_ms: Annotated[ int | None, cyclopts.Parameter( - alias="--duration", help=( - "Size the run by time instead of sample count: issue " - "target_qps × duration samples (ms, or with suffix: 600s, 10m; " - "None = issue the dataset once). Poisson only — requires an " - "explicit target_qps" + "POISSON MODE ONLY (requires an explicit target_qps; rejected " + "for offline/max_throughput and concurrency runs). Size the " + "run by time: issue target_qps × this duration worth of " + "samples (ms, or suffix: 600s, 10m). Precedence: an explicit " + "--num-samples always wins; unset, this derivation applies; " + "both unset = issue the dataset once" ), ), ] = Field( None, gt=0, description=( - "Minimum test duration in ms; sizes the run as target_qps × " - "duration samples (None = no duration target, issue the dataset once)" + "Minimum test duration in ms (poisson only; requires explicit " + "target_qps): sizes the run as target_qps × duration samples. " + "Overridden by an explicit n_samples_to_issue; None = no duration " + "target (issue the dataset once)" ), ) max_duration_ms: int | None = Field( @@ -1038,7 +1041,8 @@ def _min_duration_requires_qps(self) -> Self: or self.load_pattern.target_qps is None ): raise ValueError( - "runtime.min_duration_ms (--duration) requires a poisson load " + "runtime.min_duration_ms (--runtime.min-duration-ms) requires " + "a poisson load " "pattern with an explicit target_qps; offline/max_throughput " "and concurrency runs are sized by --num-samples or the " "dataset size" diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 29d21c708..bcc5f5b41 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -51,7 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: null # Minimum test duration in ms; sizes the run as target_qps × duration samples (None = no duration target, issue the dataset once) + min_duration_ms: null # Minimum test duration in ms (poisson only; requires explicit target_qps): sizes the run as target_qps × duration samples. Overridden by an explicit n_samples_to_issue; None = no duration target (issue the dataset once) max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index bff241eb9..a170f50ca 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -51,7 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: null # Minimum test duration in ms; sizes the run as target_qps × duration samples (None = no duration target, issue the dataset once) + min_duration_ms: null # Minimum test duration in ms (poisson only; requires explicit target_qps): sizes the run as target_qps × duration samples. Overridden by an explicit n_samples_to_issue; None = no duration target (issue the dataset once) max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 9a9872942..58a10b5ac 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -51,7 +51,7 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: null # Minimum test duration in ms; sizes the run as target_qps × duration samples (None = no duration target, issue the dataset once) + min_duration_ms: null # Minimum test duration in ms (poisson only; requires explicit target_qps): sizes the run as target_qps × duration samples. Overridden by an explicit n_samples_to_issue; None = no duration target (issue the dataset once) max_duration_ms: null # Maximum test duration in ms (None for no limit) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed diff --git a/tests/performance/commands/test_e2e_perf.py b/tests/performance/commands/test_e2e_perf.py index e1fa2d1d7..986dc425a 100644 --- a/tests/performance/commands/test_e2e_perf.py +++ b/tests/performance/commands/test_e2e_perf.py @@ -143,7 +143,7 @@ def test_concurrency_roofline( "concurrency", "--concurrency", str(concurrency), - "--duration", + "--runtime.min-duration-ms", "10s", "--runtime.max-duration-ms", "12000", @@ -203,7 +203,7 @@ def test_poisson_binary_search_max_qps( "poisson", "--target-qps", str(target), - "--duration", + "--runtime.min-duration-ms", "10s", "--runtime.max-duration-ms", "12000", @@ -286,9 +286,9 @@ def test_low_qps_no_network_errors( "poisson", "--target-qps", str(TARGET_QPS), - "--duration", + "--runtime.min-duration-ms", f"{DURATION_S}s", - # 2x Poisson expectation so wall time (--duration) always caps + # 2x Poisson expectation so wall time (min duration) always caps # the run; without headroom, variance in inter-arrivals can # finish the test early before the full idle-connection window. "--num-samples", diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py index bff1f061c..4d2f35412 100644 --- a/tests/unit/config/test_schema.py +++ b/tests/unit/config/test_schema.py @@ -527,7 +527,7 @@ def test_max_duration_defaults_to_none_in_runtime_settings(self): @pytest.mark.unit def test_min_duration_sizes_the_run(self): - """--duration (runtime.min_duration_ms) drives target_qps × duration + """runtime.min_duration_ms drives target_qps × duration sample-count derivation, with suffix parsing; None = dataset once.""" from inference_endpoint.config.runtime_settings import RuntimeSettings From 8e43f7a6539f1ffd8e524043a230eba18dcc6c43 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 17:36:32 -0700 Subject: [PATCH 24/45] feat(interrupt): second distinct ^C force-quits; drop stray --duration spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-world report: a run with a 2.2M-item tokenization backlog wedged in the unbounded metrics drain after ^C, and every further ^C was suppressed. Second distinct ^C now cancels the run task — the unwind kills the service children (the SIGTERMed aggregator writes a best-effort INTERRUPTED snapshot without finishing its drain), salvages tmpfs, exits 130. Re-deliveries within 1s remain one keystroke (uv run & co. forward the group SIGINT), so graceful ^C keeps working under wrappers. Cleanups: warmup/accuracy phase settings use the canonical min_duration_ms=None; the e2e concurrency perf test no longer passes the poisson-only min-duration flag; the standalone httpclient bench renames its unrelated --duration to --duration-s. --- docs/CLI_QUICK_REFERENCE.md | 10 +-- .../commands/benchmark/execute.py | 17 ++++- .../commands/benchmark/watchdog.py | 64 ++++++++++++++----- .../utils/benchmark_httpclient.py | 3 +- tests/performance/commands/test_e2e_perf.py | 2 - tests/unit/commands/test_benchmark.py | 5 +- 6 files changed, 73 insertions(+), 28 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 773ca200b..de886c940 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -193,10 +193,12 @@ One handler owns SIGINT for the whole run: released, buffered samples still reach the metrics aggregator, and the artifacts land honest — `final_snapshot.json` `state: interrupted`, `result_summary.json` `complete: false`, `events.jsonl` flushed. Exit 130. -- **Further ^C**: no-op. One keystroke can be delivered repeatedly (process - runners like `uv run` forward the terminal's group SIGINT to a child that - already received it), so repeats are indistinguishable from the first. A - wedged teardown is bounded by `run_timeout_s` or killed externally. +- **^C again (a distinct later press)**: force quit — the teardown (metrics + drain included) is abandoned, service children are killed, exit 130 with + whatever artifacts were already written. Rapid re-deliveries within ~1 s + are the same keystroke (process runners like `uv run` forward the + terminal's group SIGINT to a child that already received it) and never + escalate. - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 191948ba2..d25c76296 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -596,7 +596,7 @@ def _build_phases( ) warmup_rt = dataclass_replace( ctx.rt_settings, - min_duration_ms=0, + min_duration_ms=None, max_duration_ms=None, n_samples_from_dataset=ctx.dataloader.num_samples(), n_samples_to_issue=warmup_cfg.n_requests, @@ -663,7 +663,7 @@ def _build_phases( acc_settings = RuntimeSettings( metric_target=rng_settings.metric_target, reported_metrics=rng_settings.reported_metrics, - min_duration_ms=0, + min_duration_ms=None, max_duration_ms=None, n_samples_from_dataset=acc_ds.num_samples(), n_samples_to_issue=acc_ds.num_samples() * acc_ds.repeats, @@ -820,6 +820,8 @@ async def _run_benchmark_async( watchdog = RunWatchdog(loop, deadline, pipe) watchdog.bind_task(asyncio.current_task()) + if sigint is not None: + sigint.bind_task(asyncio.current_task(), loop) try: tmpfs_dir.mkdir(parents=True, exist_ok=True) @@ -896,7 +898,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: profiler.start() if sigint is not None: - sigint.bind_session(session, loop) + sigint.bind_session(session) try: # A pre-session fire already stopped the session inside # bind_session: zero samples issue, STARTED/ENDED still @@ -997,6 +999,15 @@ def _on_phase_start(phase: PhaseConfig) -> None: salvage_err, tmpfs_dir, ) + if ( + sigint is not None + and sigint.forced + and isinstance(e, asyncio.CancelledError) + ): + # Second ^C cancelled this task to abandon the teardown; the + # pipeline __aexit__ has killed the service children above. + # Surface as the user's Ctrl-C, not a bare cancellation. + raise KeyboardInterrupt from e if watchdog.fired and isinstance(e, Exception | asyncio.CancelledError): # The watchdog aborted the run: the pre-session fire cancels this # task, and a mid-teardown fire can surface as a launch/drain diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index a54b3aac6..ed167a16c 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -46,38 +46,70 @@ class SigintGovernor: KeyboardInterrupt and abort teardown half-way. Semantics: - - ^C with no live session (sync setup): nothing to stop gracefully — + - ^C with no live run task (sync setup): nothing to stop gracefully — raise KeyboardInterrupt immediately (default behavior, exit 130). - - First ^C with a session bound: graceful — ``session.stop()``; the - stopped run publishes INTERRUPTED+ENDED, services drain, artifacts land - as state=interrupted, then ``run_benchmark`` raises for exit 130. - - Every later ^C is a no-op: one keystroke can be DELIVERED repeatedly - (process-runner wrappers like ``uv run`` forward the terminal's group - SIGINT to a child that already got it directly), so "another ^C" - cannot be told apart from the same one. A wedged teardown is bounded - by ``run_timeout_s`` or killed externally. + - First ^C: graceful — ``session.stop()``; the stopped run publishes + INTERRUPTED+ENDED, services drain (including the metrics-tokenization + backlog), artifacts land as state=interrupted, then ``run_benchmark`` + raises for exit 130. + - Re-deliveries inside the burst window are the SAME keystroke: wrappers + sharing the foreground group (``uv run``, ``npm exec``, ...) forward + the terminal's group SIGINT to a child that already got it directly. + Never an escalation. + - A later distinct ^C: FORCE QUIT — the run task is cancelled, so the + teardown (metrics drain included) is abandoned; the pipeline + ``__aexit__`` kills the service children (the SIGTERMed aggregator + writes a best-effort INTERRUPTED snapshot without finishing its + drain), tmpfs is salvaged, exit 130. """ + _BURST_WINDOW_S = 1.0 + def __init__(self) -> None: self.interrupted = False + self.forced = False + self._last_at = 0.0 self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None self._loop: asyncio.AbstractEventLoop | None = None - def bind_session( - self, session: BenchmarkSession, loop: asyncio.AbstractEventLoop + def bind_task( + self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop ) -> None: - self._session = session + """Bind the run coroutine's task — the force-quit cancellation target.""" + self._task = task self._loop = loop + def bind_session(self, session: BenchmarkSession) -> None: + self._session = session + def __call__(self, signum: int, frame: object) -> None: + now = time.monotonic() if self.interrupted: - # ponytail: repeat ^C is a no-op; add a distinct-keystroke - # force-quit only if a real wedged-teardown report demands it. - return + if now - self._last_at < self._BURST_WINDOW_S: + return # same keystroke, re-delivered by a wrapper + self.forced = True + logger.warning( + "second SIGINT: force quit — abandoning teardown/metrics drain" + ) + if ( + self._task is not None + and not self._task.done() + and self._loop is not None + and self._loop.is_running() + ): + # Cancelling the run task unwinds its finallys: the pipeline + # __aexit__ kills the service children and tmpfs is salvaged. + self._loop.call_soon_threadsafe(self._task.cancel) + return + raise KeyboardInterrupt self.interrupted = True + self._last_at = now if self._session is None or self._loop is None: raise KeyboardInterrupt - logger.warning("SIGINT received: stopping benchmark gracefully") + logger.warning( + "SIGINT received: stopping benchmark gracefully (^C again to force)" + ) # A signal handler runs at an arbitrary bytecode boundary — possibly # mid-event-loop-iteration. Don't mutate asyncio state (Event.set, # Task.cancel) from here; hand session.stop to the loop, the one diff --git a/src/inference_endpoint/utils/benchmark_httpclient.py b/src/inference_endpoint/utils/benchmark_httpclient.py index cb0e4ecbe..91e2d5c68 100644 --- a/src/inference_endpoint/utils/benchmark_httpclient.py +++ b/src/inference_endpoint/utils/benchmark_httpclient.py @@ -1384,7 +1384,8 @@ def main() -> None: ) parser.add_argument( "-d", - "--duration", + "--duration-s", + dest="duration", type=float, default=5.0, help="Benchmark duration in seconds (default: 5)", diff --git a/tests/performance/commands/test_e2e_perf.py b/tests/performance/commands/test_e2e_perf.py index 986dc425a..2a88f7578 100644 --- a/tests/performance/commands/test_e2e_perf.py +++ b/tests/performance/commands/test_e2e_perf.py @@ -143,8 +143,6 @@ def test_concurrency_roofline( "concurrency", "--concurrency", str(concurrency), - "--runtime.min-duration-ms", - "10s", "--runtime.max-duration-ms", "12000", # Headroom so wall time, not sample count, is the limit. diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 3a44d5df0..cfa455c26 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -1682,7 +1682,8 @@ def test_warmup_phase_uses_max_throughput(self, base_rt_settings, simple_dataset assert warmup_rt.load_pattern.type == LoadPatternType.MAX_THROUGHPUT @pytest.mark.unit - def test_warmup_phase_min_duration_is_zero(self, base_rt_settings, simple_dataset): + def test_warmup_phase_no_duration_target(self, base_rt_settings, simple_dataset): + """Warmup never inherits the perf run's duration sizing.""" config = OfflineConfig( **_OFFLINE_KWARGS, settings=OfflineSettings(warmup=WarmupConfig(enabled=True)), @@ -1690,7 +1691,7 @@ def test_warmup_phase_min_duration_is_zero(self, base_rt_settings, simple_datase ctx = self._make_ctx(config, base_rt_settings, simple_dataset) phases = _build_phases(ctx) - assert phases[0].runtime_settings.min_duration_ms == 0 + assert phases[0].runtime_settings.min_duration_ms is None @pytest.mark.unit def test_warmup_phase_no_max_duration(self, base_rt_settings, simple_dataset): From 1a0ced8384b11d54feb898f39f2b91290991124b Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Wed, 19 Aug 2026 17:53:05 -0700 Subject: [PATCH 25/45] =?UTF-8?q?refactor(interrupt):=20any=20follow-up=20?= =?UTF-8?q?^C=20force-quits=20=E2=80=94=20no=20burst-window=20heuristic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First ^C stops gracefully; every further SIGINT cancels the run task (teardown/metrics drain abandoned, service children killed, best-effort INTERRUPTED snapshot, exit 130). No timing state. Known caveat, documented: wrappers that forward the terminal's group SIGINT (uv run) deliver one keystroke twice, so a single ^C under them force-quits immediately. --- docs/CLI_QUICK_REFERENCE.md | 12 ++++++------ .../commands/benchmark/watchdog.py | 15 ++------------- 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index de886c940..1cfcec339 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -193,12 +193,12 @@ One handler owns SIGINT for the whole run: released, buffered samples still reach the metrics aggregator, and the artifacts land honest — `final_snapshot.json` `state: interrupted`, `result_summary.json` `complete: false`, `events.jsonl` flushed. Exit 130. -- **^C again (a distinct later press)**: force quit — the teardown (metrics - drain included) is abandoned, service children are killed, exit 130 with - whatever artifacts were already written. Rapid re-deliveries within ~1 s - are the same keystroke (process runners like `uv run` forward the - terminal's group SIGINT to a child that already received it) and never - escalate. +- **Any further ^C**: force quit — the teardown (metrics drain included) is + abandoned, service children are killed (the aggregator still writes a + best-effort `interrupted` snapshot), exit 130 with whatever artifacts were + already written. Note: process runners that forward the terminal's group + SIGINT to their child (`uv run` does) deliver one keystroke twice — under + such wrappers a single ^C therefore force-quits immediately. - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index ed167a16c..666fb103d 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -52,23 +52,16 @@ class SigintGovernor: INTERRUPTED+ENDED, services drain (including the metrics-tokenization backlog), artifacts land as state=interrupted, then ``run_benchmark`` raises for exit 130. - - Re-deliveries inside the burst window are the SAME keystroke: wrappers - sharing the foreground group (``uv run``, ``npm exec``, ...) forward - the terminal's group SIGINT to a child that already got it directly. - Never an escalation. - - A later distinct ^C: FORCE QUIT — the run task is cancelled, so the + - Any further ^C: FORCE QUIT — the run task is cancelled, so the teardown (metrics drain included) is abandoned; the pipeline ``__aexit__`` kills the service children (the SIGTERMed aggregator writes a best-effort INTERRUPTED snapshot without finishing its drain), tmpfs is salvaged, exit 130. """ - _BURST_WINDOW_S = 1.0 - def __init__(self) -> None: self.interrupted = False self.forced = False - self._last_at = 0.0 self._session: BenchmarkSession | None = None self._task: asyncio.Task | None = None self._loop: asyncio.AbstractEventLoop | None = None @@ -84,13 +77,10 @@ def bind_session(self, session: BenchmarkSession) -> None: self._session = session def __call__(self, signum: int, frame: object) -> None: - now = time.monotonic() if self.interrupted: - if now - self._last_at < self._BURST_WINDOW_S: - return # same keystroke, re-delivered by a wrapper self.forced = True logger.warning( - "second SIGINT: force quit — abandoning teardown/metrics drain" + "SIGINT again: force quit — abandoning teardown/metrics drain" ) if ( self._task is not None @@ -104,7 +94,6 @@ def __call__(self, signum: int, frame: object) -> None: return raise KeyboardInterrupt self.interrupted = True - self._last_at = now if self._session is None or self._loop is None: raise KeyboardInterrupt logger.warning( From add1d47aa612d6f24e7c405bad60468515d45c8d Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 12:53:59 -0700 Subject: [PATCH 26/45] refactor(profiling): explicit shutdown at process exit points; no atexit Line-profiler stats are dumped by shutdown() called from the CLI run() finally, worker_main's finally, and pytest_sessionfinish. The plugin's stderr-suppression hook is gone too: with the profiler torn down before interpreter exit there are no shutdown errors to hide. --- .../endpoint_client/worker.py | 5 ++++ src/inference_endpoint/main.py | 5 ++++ .../profiling/line_profiler.py | 27 +++---------------- .../profiling/pytest_profiling_plugin.py | 15 ----------- 4 files changed, 14 insertions(+), 38 deletions(-) diff --git a/src/inference_endpoint/endpoint_client/worker.py b/src/inference_endpoint/endpoint_client/worker.py index ec49a71b0..d5ce864c5 100644 --- a/src/inference_endpoint/endpoint_client/worker.py +++ b/src/inference_endpoint/endpoint_client/worker.py @@ -46,6 +46,7 @@ PooledConnection, ) from inference_endpoint.profiling import profile +from inference_endpoint.profiling import shutdown as profiling_shutdown from inference_endpoint.utils.logging import setup_logging logger = logging.getLogger(__name__) @@ -122,6 +123,10 @@ def worker_main( except Exception as e: logger.error(f"Crashed: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}") sys.exit(1) + finally: + # Dump this worker's line-profiler stats to its per-PID logfile + # before the process exits (no-op unless ENABLE_LINE_PROFILER=1). + profiling_shutdown() class Worker: diff --git a/src/inference_endpoint/main.py b/src/inference_endpoint/main.py index abae50643..05161e91c 100644 --- a/src/inference_endpoint/main.py +++ b/src/inference_endpoint/main.py @@ -42,6 +42,7 @@ InputValidationError, SetupError, ) +from inference_endpoint.profiling import shutdown as profiling_shutdown from inference_endpoint.utils.logging import setup_logging logger = logging.getLogger(__name__) @@ -152,6 +153,10 @@ def run() -> None: except Exception: traceback.print_exc() sys.exit(1) + finally: + # Dump any pending line-profiler stats before the process exits + # (no-op unless ENABLE_LINE_PROFILER=1). + profiling_shutdown() if __name__ == "__main__": diff --git a/src/inference_endpoint/profiling/line_profiler.py b/src/inference_endpoint/profiling/line_profiler.py index 56c2d659e..f27d29db5 100644 --- a/src/inference_endpoint/profiling/line_profiler.py +++ b/src/inference_endpoint/profiling/line_profiler.py @@ -20,10 +20,10 @@ - Controlled via ENABLE_LINE_PROFILER environment variable - No-op decorators when disabled (zero overhead) - Support for both sync and async functions -- Automatic cleanup on process exit +- Stats are dumped by an explicit ``shutdown()`` at each process's exit + point (CLI ``run()``, ``worker_main``, pytest sessionfinish) — no atexit """ -import atexit import contextlib import io import os @@ -70,7 +70,6 @@ def __init__(self): self._stats_printed = False logfile = os.environ.get(ENV_VAR_LINE_PROFILER_LOGFILE, None) self.output_file = Path(logfile) if logfile else None - self._atexit_registered = False if self.enabled: if LineProfiler is None: @@ -80,25 +79,9 @@ def __init__(self): ) self.profiler = LineProfiler() self.profiler.enable() - atexit.register(self._safe_cleanup) - self._atexit_registered = True - - def _safe_cleanup(self): - """Safe cleanup wrapper that suppresses all errors during atexit.""" - if not self._atexit_registered: - return - - try: - self._cleanup() - except: # noqa: E722 - pass # Suppress all errors during shutdown def _cleanup(self): - """Cleanup function called at interpreter exit or explicit shutdown. - - Prints stats (if any) and then completely tears down the profiler - to prevent shutdown errors. - """ + """Print pending stats and tear the profiler down. Idempotent.""" if not self.profiler or self._stats_printed or not self.profiler.functions: self._teardown_profiler() return @@ -182,11 +165,9 @@ def pause(self): pass # Already torn down def shutdown(self): - """Explicit shutdown for worker processes. Safe to call multiple times.""" + """Print pending stats and tear down. Safe to call multiple times.""" if self._stats_printed: return - - self._atexit_registered = False # Prevent double-printing via atexit self._cleanup() def is_enabled(self) -> bool: diff --git a/src/inference_endpoint/profiling/pytest_profiling_plugin.py b/src/inference_endpoint/profiling/pytest_profiling_plugin.py index 3a2680612..b077128cc 100644 --- a/src/inference_endpoint/profiling/pytest_profiling_plugin.py +++ b/src/inference_endpoint/profiling/pytest_profiling_plugin.py @@ -23,7 +23,6 @@ - Ensures clean output even on test failures """ -import atexit import glob import os import shutil @@ -50,9 +49,6 @@ def pytest_configure(config): "/tmp/mlperf_client_profiles/profile" ) - # Suppress stderr during interpreter shutdown to hide line_profiler internal errors - atexit.register(_suppress_stderr_during_shutdown) - def pytest_sessionfinish(session, exitstatus): """Print profiling results after test session completes.""" @@ -108,14 +104,3 @@ def _cleanup_profile_files(output_file: str): shutil.rmtree(profile_dir, ignore_errors=True) except Exception: pass # Silently fail cleanup - - -def _suppress_stderr_during_shutdown(): - """Suppress stderr at OS level to hide harmless line_profiler shutdown errors.""" - try: - # Redirect stderr file descriptor to /dev/null - devnull = os.open(os.devnull, os.O_WRONLY) - os.dup2(devnull, 2) - os.close(devnull) - except Exception: - pass # Silently fail if stderr redirection fails From 6576d9b4de9d2dd8d85623bc24bd816c4766e152 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 12:54:55 -0700 Subject: [PATCH 27/45] feat(interrupt): force quit is immediate, one keystroke counts once; review-round fixes Force-quit (second distinct ^C) now abandons everything without grace: the pipeline teardown SIGKILLs the service children on every unwind path (force_quit predicate), HTTP workers are SIGKILLed with no graceful wait, and the tmpfs salvage is skipped. First ^C keeps the graceful path: report for whatever is already computed, normal drain budget, exit 130. Runners that forward the terminal's group SIGINT (uv run) deliver a single ^C twice, the forwarded copy ~200ms later; deliveries within 1s of the last accepted one are dropped as duplicates so one keystroke never force-quits. Locked by integration tests: wedged-aggregator force quit, pre-session ^C, and a real uv-run one-keystroke run. The audit runner installs its own SigintGovernor around the phase loop and passes it to every phase, so a ^C during a long audit phase takes the graceful path instead of a raw KeyboardInterrupt that skips finalize; a ^C between phases refuses to start another phase. Review-round fixes: split-brain guard keys on report.state (catches the drain-timeout subcase of an aborted run); governor binds beside the watchdog at session creation; SIGINT-handler restore uses a sentinel (None is a restorable C-installed handler); whole-run deadline derivation shared via _run_deadline; run_benchmark fails loudly when no usable report exists; the metrics-drain failure message names the tokenizer-failure cause; signal frame typed FrameType | None; run_timeout_s description states the setup-boundary limitation; tokenizer-workers doc default is 4. --- docs/CLI_QUICK_REFERENCE.md | 16 +- .../services/metrics_aggregator/DESIGN.md | 4 +- src/inference_endpoint/commands/audit.py | 77 ++++++-- .../commands/benchmark/execute.py | 178 +++++++++++------ .../commands/benchmark/pipeline.py | 32 ++- .../commands/benchmark/watchdog.py | 27 ++- src/inference_endpoint/config/schema.py | 18 +- .../templates/concurrency_template_full.yaml | 2 +- .../templates/offline_template_full.yaml | 2 +- .../templates/online_template_full.yaml | 2 +- .../endpoint_client/http_client.py | 10 + .../endpoint_client/worker_manager.py | 10 + tests/integration/commands/test_sigint.py | 183 ++++++++++++++++++ tests/unit/compliance/test_output_caching.py | 6 +- 14 files changed, 457 insertions(+), 110 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 1cfcec339..3eaeaa951 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -175,8 +175,10 @@ run_benchmark ── run_timeout_s deadline captured here ─────── How the knobs compose: -1. **`--num-samples` / dataset-once defines the work.** An explicit `runtime.n_samples_to_issue` - sets the sample count; omitting it issues the performance dataset once. +1. **`--num-samples` / duration / dataset-once defines the work.** An explicit + `runtime.n_samples_to_issue` sets the sample count; otherwise `runtime.min_duration_ms` + (poisson only) derives it as QPS x duration; with neither set, the performance dataset is + issued once. 2. **`runtime.max_duration_ms` caps performance-phase issuing** and ends the phase normally — remaining samples are not issued, in-flight requests are abandoned (no drain), the report is valid. It does not bound the drain: issuing and draining are consecutive, never concurrent. @@ -194,11 +196,11 @@ One handler owns SIGINT for the whole run: artifacts land honest — `final_snapshot.json` `state: interrupted`, `result_summary.json` `complete: false`, `events.jsonl` flushed. Exit 130. - **Any further ^C**: force quit — the teardown (metrics drain included) is - abandoned, service children are killed (the aggregator still writes a - best-effort `interrupted` snapshot), exit 130 with whatever artifacts were - already written. Note: process runners that forward the terminal's group - SIGINT to their child (`uv run` does) deliver one keystroke twice — under - such wrappers a single ^C therefore force-quits immediately. + abandoned, service children and HTTP workers are SIGKILLed, exit 130 with + whatever artifacts were already written. One keystroke counts once: runners + that forward the terminal's group SIGINT to their child (`uv run` does) + deliver a single ^C twice microseconds apart — the duplicate delivery is + suppressed, so only a deliberate second press forces. - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. diff --git a/docs/async_utils/services/metrics_aggregator/DESIGN.md b/docs/async_utils/services/metrics_aggregator/DESIGN.md index 323372980..6a301c28e 100644 --- a/docs/async_utils/services/metrics_aggregator/DESIGN.md +++ b/docs/async_utils/services/metrics_aggregator/DESIGN.md @@ -117,11 +117,11 @@ COMPLETE event ─► trigger.fire ─► queue.enqueue(text, on_count) [ | `--publish-interval` | 0.25 | Live snapshot cadence (seconds) | | `--drain-timeout` | `0` (unlimited) | End-of-run tokenize budget (`0` = unlimited) | | `--tokenizer` | none | HF name or local path; unset disables token metrics | -| `--tokenizer-workers` | `2` | Live in-process threads (`0` = defer all to drain) | +| `--tokenizer-workers` | `4` | Live in-process threads (`0` = defer all to drain) | | `--streaming` | off | Register TTFT/chunk-delta/TPOT triggers | `--drain-timeout` and `--tokenizer-workers` have service-side defaults (`0` -and `2`) so the service is launchable by hand without tuning knobs, but +and `4`) so the service is launchable by hand without tuning knobs, but the config schema is the single source of truth (`settings.timeouts.metrics_drain_timeout_s` in `config/schema.py`, `settings.metrics_tokenizer_workers` in `config/schema.py`): the benchmark always forwards the schema values (`--metrics-drain-timeout`, diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index 9df903e26..bf07a55a9 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -30,13 +30,15 @@ import logging import shutil +import signal from pathlib import Path -from ..compliance import AuditRunArtifacts, get_audit_test +from ..compliance import AuditRunArtifacts, AuditRunSpec, AuditTest, get_audit_test from ..compliance.result import AuditResult, write_result -from ..config.schema import BenchmarkConfig, DatasetType +from ..config.schema import AuditConfig, BenchmarkConfig, DatasetType from ..exceptions import ExecutionError, SetupError from .benchmark.execute import ( + _SIGINT_NOT_INSTALLED, BenchmarkResult, TestMode, _salvage_tmpfs, @@ -44,6 +46,7 @@ run_benchmark_async, setup_benchmark, ) +from .benchmark.watchdog import SigintGovernor logger = logging.getLogger(__name__) @@ -76,6 +79,52 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: specs = test.plan_runs(audit_cfg) + # One SIGINT policy for the whole audit — same governor pattern as + # run_benchmark (which restored the previous handler before run_audit + # started). First ^C stops the current phase gracefully; the phase then + # surfaces as report.state=="interrupted" below and aborts the audit. + # The flag persisting across phases is moot: an interrupted phase raises + # before the next phase starts. + sigint = SigintGovernor() + prev_sigint: object = _SIGINT_NOT_INSTALLED + try: + prev_sigint = signal.signal(signal.SIGINT, sigint) + except ValueError: + pass # not the main thread (embedded use): governor stays passive + try: + artifacts = _run_phases(config, base_report_dir, test, audit_cfg, specs, sigint) + finally: + if prev_sigint is not _SIGINT_NOT_INSTALLED: + signal.signal(signal.SIGINT, prev_sigint) # type: ignore[arg-type] + + # Normalizes verify()'s zero-QPS ValueError to exit 4, not a traceback. + try: + result = test.verify(artifacts, audit_cfg) + except (SetupError, ExecutionError): + raise + except Exception as exc: + raise ExecutionError(f"Audit verification failed: {exc}") from exc + write_result(result, base_report_dir) + + status = "PASS" if result.passed else "FAIL" + logger.info( + "Audit %s %s — %s", + audit_cfg.test, + status, + result.details.get("reason", ""), + ) + return result + + +def _run_phases( + config: BenchmarkConfig, + base_report_dir: Path, + test: AuditTest, + audit_cfg: AuditConfig, + specs: list[AuditRunSpec], + sigint: SigintGovernor, +) -> list[AuditRunArtifacts]: + """Execute the planned phases back-to-back; see ``run_audit``.""" perf_datasets = [d for d in config.datasets if d.type == DatasetType.PERFORMANCE] if not perf_datasets: raise SetupError("Audit requires at least one performance dataset") @@ -89,6 +138,10 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: artifacts: list[AuditRunArtifacts] = [] dataset_size: int | None = None for spec in specs: + if sigint.interrupted: + # A ^C that landed between phases hit a stale (finished) session + # and stopped nothing — never start another phase after it. + raise KeyboardInterrupt(f"Audit interrupted before phase '{spec.label}'") phase_dir = base_report_dir / spec.label phase_dir.mkdir(parents=True, exist_ok=True) @@ -114,7 +167,7 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: test.validate( audit_cfg, dataset_size, config.settings.load_pattern.type ) - bench = run_benchmark_async(ctx) + bench = run_benchmark_async(ctx, sigint=sigint) finalize_benchmark(ctx, bench) except (SetupError, ExecutionError): raise @@ -165,20 +218,4 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: ) ) - # Normalizes verify()'s zero-QPS ValueError to exit 4, not a traceback. - try: - result = test.verify(artifacts, audit_cfg) - except (SetupError, ExecutionError): - raise - except Exception as exc: - raise ExecutionError(f"Audit verification failed: {exc}") from exc - write_result(result, base_report_dir) - - status = "PASS" if result.passed else "FAIL" - logger.info( - "Audit %s %s — %s", - audit_cfg.test, - status, - result.details.get("reason", ""), - ) - return result + return artifacts diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index d25c76296..84eb0b552 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -809,6 +809,9 @@ async def _run_benchmark_async( event_log_dir=event_log_dir, metrics_output_dir=metrics_output_dir, loop=loop, + # Second ^C: the pipeline teardown SIGKILLs instead of the graceful + # SIGTERM-and-wait, regardless of where the cancellation unwound from. + force_quit=lambda: sigint is not None and sigint.forced, ) report: Report | None = None profiler: ProfileController @@ -860,6 +863,8 @@ async def _run_benchmark_async( session_id=session_id, ) watchdog.bind_session(session) + if sigint is not None: + sigint.bind_session(session) phases = _build_phases(ctx, perf_strategy=agentic_inference_strategy) max_duration_ms = ( @@ -897,8 +902,6 @@ def _on_phase_start(phase: PhaseConfig) -> None: # issued, so the server is armed when traffic begins. profiler.start() - if sigint is not None: - sigint.bind_session(session) try: # A pre-session fire already stopped the session inside # bind_session: zero samples issue, STARTED/ENDED still @@ -945,31 +948,44 @@ def _on_phase_start(phase: PhaseConfig) -> None: # Unifies the clean phase-end path and the abort path — both # reach this block. A watchdog abort counts as an abort even # when session.run returned normally after session.stop(). - profiler.stop(session_completed_normally and not watchdog.fired) - # Graceful drain runs on both the clean-finish and session- - # failure paths (BenchmarkSession.run publishes ENDED in its own - # finally, so a failed run still has a terminal snapshot worth - # draining). Nulls pipe.publisher so __aexit__ releases the ZMQ - # scope without killing the services. - try: - report = await pipe.drain_and_build_report() - if report is None: - raise ExecutionError( - "Benchmark completed without a usable metrics report" + # Skipped on force-quit: no blocking HTTP on the way out. + if not (sigint is not None and sigint.forced): + profiler.stop(session_completed_normally and not watchdog.fired) + if sigint is not None and sigint.forced: + # Second ^C: abandon the drain entirely — SIGKILL the + # service children now so the pipeline __aexit__ has + # nothing left to wait for. No report, no metrics + # salvage; the run exits 130 immediately. + pipe.kill_now() + else: + # Graceful drain runs on both the clean-finish and + # session-failure paths (BenchmarkSession.run publishes + # ENDED in its own finally, so a failed run still has a + # terminal snapshot worth draining). Nulls + # pipe.publisher so __aexit__ releases the ZMQ scope + # without killing the services. + try: + report = await pipe.drain_and_build_report() + if report is None: + raise ExecutionError( + "Benchmark completed without a usable " + "metrics report" + ) + except Exception as e: # noqa: BLE001 + # On a clean run a drain / report-build failure must + # be loud: silently returning report=None would exit + # 0 with no perf artifacts. On the session-failure + # path the run is already raising, so swallow it + # there rather than let a teardown error replace the + # in-flight exception; run_benchmark still fails the + # run on a missing report. + if session_completed_normally: + raise + logger.warning( + "Drain/report build error suppressed (run " + "already failing): %s", + e, ) - except Exception as e: # noqa: BLE001 - # On a clean run a drain / report-build failure must be loud: - # silently returning report=None would exit 0 with no perf - # artifacts. On the session-failure path the run is already - # raising, so swallow it there rather than let a teardown - # error replace the in-flight exception. - if session_completed_normally: - raise - logger.warning( - "Drain/report build error suppressed (run already " - "failing): %s", - e, - ) finally: # Runs on every path, including a setup error before session.run # (which never reaches the session finally above). pbar.close() is @@ -984,12 +1000,23 @@ def _on_phase_start(phase: PhaseConfig) -> None: except Exception as e: # noqa: BLE001 — progress bar is cosmetic logger.warning("Progress bar close error: %s", e) if http_client is not None: - try: - await http_client.shutdown_async() - except Exception as e: # noqa: BLE001 — best-effort; idempotent - logger.warning(f"Client cleanup error: {e}") + if sigint is not None and sigint.forced: + # Second ^C: SIGKILL the worker processes — no graceful + # wait, no transport teardown; the process is exiting. + http_client.kill_workers() + else: + try: + await http_client.shutdown_async() + except Exception as e: # noqa: BLE001 — best-effort; idempotent + logger.warning(f"Client cleanup error: {e}") except BaseException as e: - if tmpfs_dir.exists(): + # Force-quit wins over exception identity: once the user pressed ^C + # twice, the exit is theirs no matter what the cancellation unwound + # into on its way out. + forced_quit = sigint is not None and sigint.forced + # Force-quit abandons even the tmpfs salvage; every other abnormal + # path preserves the event log. + if tmpfs_dir.exists() and not forced_quit: try: _salvage_tmpfs(ctx.report_dir, tmpfs_dir) shutil.rmtree(tmpfs_dir, ignore_errors=True) @@ -999,14 +1026,15 @@ def _on_phase_start(phase: PhaseConfig) -> None: salvage_err, tmpfs_dir, ) - if ( - sigint is not None - and sigint.forced - and isinstance(e, asyncio.CancelledError) - ): - # Second ^C cancelled this task to abandon the teardown; the - # pipeline __aexit__ has killed the service children above. - # Surface as the user's Ctrl-C, not a bare cancellation. + if forced_quit: + # Second ^C: the pipeline __aexit__ above already SIGKILLed the + # service children (force_quit predicate); kill the HTTP workers + # too in case the cancellation landed inside their graceful + # shutdown await. Both are idempotent. Surface as the user's + # Ctrl-C, not a bare cancellation. + pipe.kill_now() + if http_client is not None: + http_client.kill_workers() raise KeyboardInterrupt from e if watchdog.fired and isinstance(e, Exception | asyncio.CancelledError): # The watchdog aborted the run: the pre-session fire cancels this @@ -1033,6 +1061,23 @@ def _on_phase_start(phase: PhaseConfig) -> None: ) +_SIGINT_NOT_INSTALLED = object() +"""Sentinel separating "governor never installed" from a ``None`` previous +handler (``signal.signal`` returns ``None`` for C-installed handlers, which +must still be restored).""" + + +def _run_deadline(config: BenchmarkConfig) -> float | None: + """Monotonic deadline for ``settings.timeouts.run_timeout_s`` (None = off). + + Callers anchor it deliberately: ``run_benchmark`` before setup (the whole + run counts), ``run_benchmark_async`` at entry (each audit phase gets a + full budget). + """ + run_timeout_s = config.settings.timeouts.run_timeout_s + return None if run_timeout_s is None else time.monotonic() + run_timeout_s + + def run_benchmark_async( ctx: BenchmarkContext, *, @@ -1045,11 +1090,8 @@ def run_benchmark_async( computes its own deadline at entry, so each audit phase gets a full per-phase budget. """ - if ( - deadline is None - and (run_timeout_s := ctx.config.settings.timeouts.run_timeout_s) is not None - ): - deadline = time.monotonic() + run_timeout_s + if deadline is None: + deadline = _run_deadline(ctx.config) loop = LoopManager().default_loop return loop.run_until_complete( _run_benchmark_async(ctx, loop, deadline=deadline, sigint=sigint) @@ -1176,15 +1218,17 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: collector = bench.collector report = bench.report aborted = bench.run_timed_out or bench.user_interrupted - if report is not None and aborted and report.complete: + if report is not None and aborted and report.state == "complete": # Split-brain guard: the aggregator may have finalized COMPLETE before # the watchdog's SIGTERM landed — or a ^C arrived after the session # already published its terminal ENDED (drain window), so the - # INTERRUPTED marker never went out. An aborted run must never publish - # complete:true artifacts, so force both fields honest before writing — - # state stays what the abort path would have recorded, and consumers - # keying on state=="complete" and not complete (the drain-timeout - # signature) don't misattribute an abort to a slow drain. + # INTERRUPTED marker never went out. Keyed on state (not the derived + # ``complete`` flag) so the drain-timeout subcase — state "complete" + # with pending tasks — is corrected too. An aborted run must never + # publish state-complete artifacts, so force both fields honest before + # writing; consumers keying on state=="complete" and not complete (the + # drain-timeout signature) then can't misattribute an abort to a slow + # drain. report = msgspec.structs.replace(report, complete=False, state="interrupted") # Write scoring artifacts + copy event log from tmpfs to disk (scorers read @@ -1266,15 +1310,14 @@ def run_benchmark( ) # Deadline for the whole-run watchdog is taken at entry so setup # (tokenizer/dataset load) counts against run_timeout_s too. - deadline: float | None = None - if (run_timeout_s := config.settings.timeouts.run_timeout_s) is not None: - deadline = time.monotonic() + run_timeout_s + deadline = _run_deadline(config) + run_timeout_s = config.settings.timeouts.run_timeout_s # The run's ONE SIGINT handler, installed here and restored in the finally - # — no window-scoped install/remove pairs anywhere else in the run (their - # gaps are where a ^C used to abort teardown as a raw KeyboardInterrupt). + # — no window-scoped install/remove pairs anywhere else in the run, so + # there is no gap where a ^C aborts teardown as a raw KeyboardInterrupt. # No session bound yet, so a ^C during setup keeps default abort behavior. sigint = SigintGovernor() - prev_sigint = None + prev_sigint: object = _SIGINT_NOT_INSTALLED try: prev_sigint = signal.signal(signal.SIGINT, sigint) except ValueError: @@ -1324,19 +1367,30 @@ def run_benchmark( # complete: false; fail loudly instead of exiting 0 on partial # ISL/OSL/TPOT stats. raise ExecutionError( - "Metrics drain timed out " + "Metrics tokenization did not finish (n_pending_tasks > 0 in " + "the final snapshot): the drain deadline expired " f"(metrics_drain_timeout_s=" - f"{config.settings.timeouts.metrics_drain_timeout_s}): " - "tokenization did not finish before the deadline; report is " - "partial (complete: false in result_summary.json)" + f"{config.settings.timeouts.metrics_drain_timeout_s}) or the " + "tokenizer failed mid-drain — see the aggregator log; report " + "is partial (complete: false in result_summary.json)" + ) + if bench.report is None: + # Aborted-without-flags path (e.g. transport closure) whose drain + # also failed: nothing above raised, but there is no report to + # stand behind — never exit 0 without one. + raise ExecutionError( + "Benchmark produced no usable metrics report; see the drain " + "errors above" ) except KeyboardInterrupt: # Salvage results (finally), then propagate to main.py -> exit 130. logger.warning("Benchmark interrupted by user") raise finally: - if prev_sigint is not None: - signal.signal(signal.SIGINT, prev_sigint) + if prev_sigint is not _SIGINT_NOT_INSTALLED: + # Restore whatever was installed before — including None (a + # C-installed handler), which `signal.signal` accepts back. + signal.signal(signal.SIGINT, prev_sigint) # type: ignore[arg-type] if bench: if bench.tmpfs_dir.exists(): try: diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index 124cd231c..75e441308 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -43,6 +43,7 @@ import json import logging import uuid +from collections.abc import Callable from pathlib import Path from types import TracebackType from typing import TYPE_CHECKING, Any @@ -212,6 +213,7 @@ def __init__( event_log_dir: Path, metrics_output_dir: Path, loop: asyncio.AbstractEventLoop, + force_quit: Callable[[], bool] | None = None, ) -> None: self._config = config self._tokenizer_name = tokenizer_name @@ -219,6 +221,10 @@ def __init__( self._event_log_dir = event_log_dir self._metrics_output_dir = metrics_output_dir self._loop = loop + # Force-quit predicate (second ^C): when true at teardown time the + # services are SIGKILLed with no SIGTERM grace — teardown must not + # wait on anything. + self._force_quit = force_quit if force_quit is not None else lambda: False self._stack: contextlib.ExitStack | None = None self._launcher: ServiceLauncher | None = None @@ -380,19 +386,37 @@ def terminate_metrics_aggregator(self) -> None: return self._launcher.terminate_module(_AGGREGATOR_MODULE) + def kill_now(self) -> None: + """SIGKILL every service child immediately; safe no-op before launch. + + Force-quit path (second ^C): no SIGTERM grace, no drain — the + aggregator's INTERRUPTED snapshot and the event logger's buffer are + deliberately abandoned. Idempotent: dead children are skipped. + """ + if self._launcher is None: + return + try: + self._launcher.kill_all() + except Exception as e: # noqa: BLE001 — teardown best-effort + logger.warning("Service kill_all error: %s", e) + def _kill_services(self) -> None: """Best-effort service termination owned by the pipeline ExitStack. Sends SIGTERM first so the metrics aggregator can flush an INTERRUPTED - final_snapshot.json via its signal handler. Escalates to SIGKILL after - a short timeout for any process that does not exit cleanly. + final_snapshot.json via its signal handler, escalating to SIGKILL after + a short timeout. Under force-quit (second ^C) it SIGKILLs immediately — + no grace, no snapshot. """ if self._launcher is None: return try: - self._launcher.terminate_all() + if self._force_quit(): + self._launcher.kill_all() + else: + self._launcher.terminate_all() except Exception as e: # noqa: BLE001 — teardown best-effort - logger.warning("Service terminate_all error: %s", e) + logger.warning("Service termination error: %s", e) def _close_publisher(self) -> None: """Best-effort publisher close (ExitStack callback).""" diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index 666fb103d..2177e1e6c 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -26,6 +26,7 @@ import asyncio import logging import time +import types from collections.abc import Callable from typing import TYPE_CHECKING @@ -52,19 +53,27 @@ class SigintGovernor: INTERRUPTED+ENDED, services drain (including the metrics-tokenization backlog), artifacts land as state=interrupted, then ``run_benchmark`` raises for exit 130. - - Any further ^C: FORCE QUIT — the run task is cancelled, so the - teardown (metrics drain included) is abandoned; the pipeline - ``__aexit__`` kills the service children (the SIGTERMed aggregator - writes a best-effort INTERRUPTED snapshot without finishing its - drain), tmpfs is salvaged, exit 130. + - Any further ^C: FORCE QUIT — the run task is cancelled, the service + children and HTTP workers are SIGKILLed, the metrics drain and tmpfs + salvage are abandoned, exit 130. + + One keystroke counts once: process runners that forward the terminal's + group SIGINT to their child (``uv run`` does) deliver a single ^C twice — + the kernel coalesces near-simultaneous deliveries, the forwarded copy can + land a few hundred ms later (~200 ms measured for ``uv run``). Deliveries + within ``_DUP_DELIVERY_WINDOW_S`` of the last accepted one are dropped as + duplicates; a deliberate later press always forces. """ + _DUP_DELIVERY_WINDOW_S = 1.0 + def __init__(self) -> None: self.interrupted = False self.forced = False self._session: BenchmarkSession | None = None self._task: asyncio.Task | None = None self._loop: asyncio.AbstractEventLoop | None = None + self._last_accepted_monotonic = float("-inf") def bind_task( self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop @@ -76,7 +85,13 @@ def bind_task( def bind_session(self, session: BenchmarkSession) -> None: self._session = session - def __call__(self, signum: int, frame: object) -> None: + def __call__(self, signum: int, frame: types.FrameType | None) -> None: + now = time.monotonic() + if now - self._last_accepted_monotonic < self._DUP_DELIVERY_WINDOW_S: + # Same keystroke, second delivery (group SIGINT + a forwarding + # runner like `uv run`) — not a user escalation. + return + self._last_accepted_monotonic = now if self.interrupted: self.forced = True logger.warning( diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index f8956d2a1..8335062f4 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -802,7 +802,16 @@ class WarmupConfig(BaseModel): class Timeouts(WithUpdatesMixin, BaseModel): - """All global waits and deadlines. ``None`` = wait indefinitely / off. + """All global waits and deadlines. + + Two value conventions, stated once here: + + - Wait bounds (``service_ready_timeout_s``, ``*_drain_timeout_s``): + ``None`` = wait indefinitely, ``0`` = zero budget (give up / skip + immediately). + - The watchdog (``run_timeout_s``): ``None`` = off; ``0`` is rejected + (``gt=0``) because a zero-length run is never meaningful — there is no + "skip" semantics for the run itself. Reaching an optional deadline means something is stuck; ``run_timeout_s`` is the whole-run watchdog — when it fires the run is aborted and the @@ -826,8 +835,11 @@ class Timeouts(WithUpdatesMixin, BaseModel): None, gt=0, description=( - "Whole-run watchdog in seconds (None = off). Covers every phase " - "including drains; firing aborts the run, marks the report " + "Whole-run watchdog in seconds (None = off). Bounds the run from " + "service launch through every phase and drain; synchronous setup " + "(tokenizer probe, dataset load) counts against the budget but is " + "only checked at its boundary — a hung setup call itself is not " + "interrupted. Firing aborts the run, marks the report " "INTERRUPTED, and exits non-zero. Never derives per-stage deadlines." ), ) diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index bcc5f5b41..b8127072d 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -85,7 +85,7 @@ settings: min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) - run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index a170f50ca..4454e922d 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -85,7 +85,7 @@ settings: min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) - run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 58a10b5ac..4db18e09f 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -86,7 +86,7 @@ settings: min_required_connections: -1 # Min connections to initialize (-1=auto, 0=disabled) worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) - run_timeout_s: null # Whole-run watchdog in seconds (None = off). Covers every phase including drains; firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/endpoint_client/http_client.py b/src/inference_endpoint/endpoint_client/http_client.py index e273f5814..c420bf356 100644 --- a/src/inference_endpoint/endpoint_client/http_client.py +++ b/src/inference_endpoint/endpoint_client/http_client.py @@ -162,6 +162,16 @@ async def shutdown_async(self) -> None: return await self._shutdown_async() + def kill_workers(self) -> None: + """SIGKILL every worker process immediately — force-quit path. + + Synchronous and loop-free (callable during teardown of a cancelled + task): no graceful wait, no transport cleanup. Marks the client shut + down so a later graceful call is a no-op. + """ + self._shutdown = True + self.worker_manager.kill_now() + async def _shutdown_async(self) -> None: """Async shutdown internals - must be called on the event loop.""" self._shutdown = True diff --git a/src/inference_endpoint/endpoint_client/worker_manager.py b/src/inference_endpoint/endpoint_client/worker_manager.py index ae0d194df..967f8999d 100644 --- a/src/inference_endpoint/endpoint_client/worker_manager.py +++ b/src/inference_endpoint/endpoint_client/worker_manager.py @@ -158,6 +158,16 @@ async def _wait_for_workers_with_liveness_check(self) -> None: except TimeoutError: continue # Loop to check liveness again + def kill_now(self) -> None: + """SIGKILL every worker immediately — force-quit path (second ^C). + + No graceful terminate, no join, no transport cleanup: the parent + process is exiting and the kernel reaps the SIGKILLed children. + """ + for worker in self.workers: + if worker.is_alive(): + worker.kill() + async def shutdown(self) -> None: """Shutdown workers and transports.""" # Terminate workers diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 9969b57f2..a04300b67 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -148,3 +148,186 @@ def test_sigint_mid_run_exits_130_with_interrupted_artifacts( break time.sleep(0.2) assert not leftovers, f"service children outlived the run: {leftovers}" + + +def _pid_of_child(needle: str, extra: str) -> int | None: + """PID of a live process whose argv mentions both needles (Linux).""" + for pid_dir in Path("/proc").iterdir(): + if not pid_dir.name.isdigit(): + continue + try: + cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\0", b" ") + except OSError: + continue # process exited mid-scan + if needle.encode() in cmdline and extra.encode() in cmdline: + return int(pid_dir.name) + return None + + +@pytest.mark.integration +def test_second_sigint_force_quits_immediately(mock_http_echo_server, tmp_path): + """Second ^C abandons a wedged metrics drain and exits 130 promptly. + + The aggregator child is SIGSTOPped to simulate a wedged drain — the exact + hang the force-quit path exists for. SIGINT goes to the MAIN process only + (``os.kill``, not the group), as the governor's contract is per-process: + the first ^C stops the session gracefully and then parks forever waiting + for the stopped aggregator; the second ^C must SIGKILL the children and + exit 130 within seconds. + """ + cli = shutil.which("inference-endpoint") + assert cli is not None, "console script must be installed in the test venv" + + report_dir = tmp_path / "report" + config_path = tmp_path / "bench.yaml" + _write_config(report_dir, mock_http_echo_server.url, config_path) + + proc = subprocess.Popen( + [cli, "benchmark", "from-config", "-c", str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + agg_pid: int | None = None + try: + ready = report_dir / "metrics" / ".ready" + deadline = time.monotonic() + 60.0 + while not ready.exists(): + assert proc.poll() is None, "benchmark died before services came up" + assert time.monotonic() < deadline, "services never became ready" + time.sleep(0.1) + time.sleep(3.0) # comfortably inside the ~120 s performance phase + + agg_pid = _pid_of_child("metrics_aggregator", str(report_dir)) + assert agg_pid is not None, "aggregator child not found" + os.kill(agg_pid, signal.SIGSTOP) # wedge the drain + + os.kill(proc.pid, signal.SIGINT) + time.sleep(2.0) # graceful path engaged; drain parked on the wedge + assert proc.poll() is None, "first ^C must keep waiting on the drain" + + os.kill(proc.pid, signal.SIGINT) + start = time.monotonic() + rc = proc.wait(timeout=15.0) + force_quit_latency = time.monotonic() - start + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + if agg_pid is not None: + try: + os.kill(agg_pid, signal.SIGKILL) # SIGKILL reaps stopped procs + except ProcessLookupError: + pass # already gone — the force-quit killed it + + assert rc == 130, f"force quit must exit 130, got {rc}" + assert ( + force_quit_latency < 10.0 + ), f"force quit took {force_quit_latency:.1f}s — the drain was not abandoned" + + # SIGKILLed children must not outlive the run. + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + leftovers = _procs_referencing(str(report_dir)) + if not leftovers: + break + time.sleep(0.2) + assert not leftovers, f"service children outlived the force quit: {leftovers}" + + +@pytest.mark.integration +def test_sigint_before_session_exits_130(mock_http_echo_server, tmp_path): + """A ^C before the session exists (setup/service launch) exits 130. + + No session is bound yet, so the governor falls back to an immediate + KeyboardInterrupt — the run must not hang or exit 0. + """ + cli = shutil.which("inference-endpoint") + assert cli is not None, "console script must be installed in the test venv" + + report_dir = tmp_path / "report" + config_path = tmp_path / "bench.yaml" + _write_config(report_dir, mock_http_echo_server.url, config_path) + + proc = subprocess.Popen( + [cli, "benchmark", "from-config", "-c", str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + try: + time.sleep(1.5) # interpreter up, setup underway; services not ready + os.killpg(proc.pid, signal.SIGINT) + rc = proc.wait(timeout=30.0) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + + assert rc == 130, f"pre-session ^C must exit 130, got {rc}" + + deadline = time.monotonic() + 10.0 + while time.monotonic() < deadline: + leftovers = _procs_referencing(str(report_dir)) + if not leftovers: + break + time.sleep(0.2) + assert not leftovers, f"children outlived the aborted run: {leftovers}" + + +@pytest.mark.integration +def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp_path): + """One keystroke under `uv run` counts once. + + `uv run` forwards the terminal's group SIGINT to its child, so a single + ^C is delivered twice (~200 ms apart, past kernel coalescing). The + duplicate must be suppressed: the run takes the graceful path — report + written, exit 130 — instead of force-quitting and losing the metrics. + """ + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv not available") + + report_dir = tmp_path / "report" + config_path = tmp_path / "bench.yaml" + _write_config(report_dir, mock_http_echo_server.url, config_path) + + proc = subprocess.Popen( + [ + uv, + "run", + "inference-endpoint", + "benchmark", + "from-config", + "-c", + str(config_path), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + try: + ready = report_dir / "metrics" / ".ready" + deadline = time.monotonic() + 90.0 + while not ready.exists(): + assert proc.poll() is None, "benchmark died before services came up" + assert time.monotonic() < deadline, "services never became ready" + time.sleep(0.1) + time.sleep(3.0) + + os.killpg(proc.pid, signal.SIGINT) # one keystroke: group + uv forward + rc = proc.wait(timeout=60.0) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + + assert rc == 130, f"user abort must exit 130, got {rc}" + + # The graceful path writes the report; a force quit would have skipped it. + summary = json.loads( + (report_dir / "performance" / "result_summary.json").read_text() + ) + assert summary["complete"] is False + snapshot = json.loads((report_dir / "metrics" / "final_snapshot.json").read_text()) + assert snapshot["state"] == "interrupted" diff --git a/tests/unit/compliance/test_output_caching.py b/tests/unit/compliance/test_output_caching.py index 505bc6864..0cc422a60 100644 --- a/tests/unit/compliance/test_output_caching.py +++ b/tests/unit/compliance/test_output_caching.py @@ -427,7 +427,7 @@ def _patch_phase(monkeypatch, *, num_samples, bench): ) monkeypatch.setattr( "inference_endpoint.commands.audit.run_benchmark_async", - lambda ctx: bench, + lambda ctx, **kwargs: bench, ) monkeypatch.setattr( "inference_endpoint.commands.audit.finalize_benchmark", @@ -528,7 +528,7 @@ def test_keyboard_interrupt_propagates(self, tmp_path, monkeypatch): ctx = MagicMock() ctx.dataloader.num_samples.return_value = 100 - def _interrupt(_ctx): + def _interrupt(_ctx, **kwargs): raise KeyboardInterrupt monkeypatch.setattr( @@ -675,7 +675,7 @@ def test_acc_mode_phase_keeps_accuracy_datasets(self, tmp_path, monkeypatch): bench.tmpfs_dir = Path("/nonexistent-tmpfs-path-for-tests") monkeypatch.setattr( "inference_endpoint.commands.audit.run_benchmark_async", - lambda ctx: bench, + lambda ctx, **kwargs: bench, ) monkeypatch.setattr( "inference_endpoint.commands.audit.finalize_benchmark", From c2d2e80cb7e15a9efe20f83f1016c0a2e250e258 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 13:14:22 -0700 Subject: [PATCH 28/45] fix(interrupt): ^C during sync finalization aborts; profile POSTs off the loop thread; profiler teardown unconditional Three correctness holes from review: 1. After run_until_complete returns, the SIGINT governor's session/task/loop stay bound but the loop is stopped - a first ^C during sync finalization (or audit inter-phase setup) queued session.stop on the dead loop and was silently swallowed. The graceful path now requires a live task on a running loop; otherwise KeyboardInterrupt is raised immediately (salvage still runs via run_benchmark's finally, exit 130). 2. ProfileController.start/stop ran sequential blocking urlopen calls (2s each) on the event-loop thread, so a queued force-quit task.cancel could not land until every POST finished. The POSTs now run in worker threads (asyncio.to_thread), awaited sequentially - identical ordering and records, but cancellation lands between POSTs. The session's on_phase_start hook is awaitable to carry this, and the perf cap is armed AFTER the awaited profile arming: _run_phase clears the phase-stop flag at entry, so a one-shot cap firing during the await would be erased and the phase would run uncapped. 3. Line-profiler shutdown() early-returned when print_stats() had already run, and a failing output destination skipped teardown - both left the C profiler enabled at exit. Teardown now runs unconditionally in a finally; _stats_printed only suppresses a duplicate dump. Regression tests: SigintGovernor state machine (no-live-run raise, duplicate-delivery drop, force cancel), hook awaited before issuing, cap-shorter-than-hook still bounds the phase, print_stats-then-shutdown and failing-destination teardown. --- .../commands/benchmark/execute.py | 23 ++-- .../commands/benchmark/profiling.py | 17 ++- .../commands/benchmark/watchdog.py | 16 ++- .../load_generator/session.py | 6 +- .../profiling/line_profiler.py | 29 ++-- .../integration/commands/test_run_timeout.py | 2 +- tests/unit/commands/test_benchmark.py | 26 ++-- tests/unit/commands/test_watchdog.py | 127 ++++++++++++++++++ .../unit/load_generator/test_async_session.py | 60 +++++++++ tests/unit/test_profiler.py | 36 +++++ 10 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 tests/unit/commands/test_watchdog.py diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 84eb0b552..a89b55b28 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -891,16 +891,17 @@ def _on_global_timeout() -> None: loop, max_duration_ms, _on_global_timeout ) - def _on_phase_start(phase: PhaseConfig) -> None: - # PerfPhaseTimeout arms the perf cap on PERFORMANCE and cancels - # it when any later phase starts, so a combined perf+accuracy run - # can never have its accuracy phase truncated by the perf cap. + async def _on_phase_start(phase: PhaseConfig) -> None: + if phase.phase_type == PhaseType.PERFORMANCE: + # Fire /start_profile sequentially before any perf request + # is issued, so the server is armed when traffic begins. + await profiler.start() + # Arm the perf cap LAST — _run_phase clears the phase-stop + # flag at entry, so a one-shot cap that fired while profile + # arming was still awaiting would be silently erased and the + # phase would run uncapped. (On non-PERFORMANCE phases this + # cancels the perf timer, so accuracy is never truncated.) perf_timeout.on_phase_start(phase.phase_type) - if phase.phase_type != PhaseType.PERFORMANCE: - return - # Fire /start_profile sequentially before any perf request is - # issued, so the server is armed when traffic begins. - profiler.start() try: # A pre-session fire already stopped the session inside @@ -950,7 +951,9 @@ def _on_phase_start(phase: PhaseConfig) -> None: # when session.run returned normally after session.stop(). # Skipped on force-quit: no blocking HTTP on the way out. if not (sigint is not None and sigint.forced): - profiler.stop(session_completed_normally and not watchdog.fired) + await profiler.stop( + session_completed_normally and not watchdog.fired + ) if sigint is not None and sigint.forced: # Second ^C: abandon the drain entirely — SIGKILL the # service children now so the pipeline __aexit__ has diff --git a/src/inference_endpoint/commands/benchmark/profiling.py b/src/inference_endpoint/commands/benchmark/profiling.py index 60db572e3..65839c612 100644 --- a/src/inference_endpoint/commands/benchmark/profiling.py +++ b/src/inference_endpoint/commands/benchmark/profiling.py @@ -24,6 +24,7 @@ from __future__ import annotations +import asyncio import logging import time from datetime import datetime @@ -170,10 +171,15 @@ def __init__( self._start_urls = _derive_profile_urls(profile_endpoints, engine, "start") self._stop_urls = _derive_profile_urls(profile_endpoints, engine, "stop") - def start(self) -> None: - """Fire /start_profile sequentially before any perf request is issued.""" + async def start(self) -> None: + """Fire /start_profile sequentially before any perf request is issued. + + Each POST runs in a worker thread so the event-loop thread never + blocks — a force-quit ``task.cancel`` lands between POSTs instead of + waiting out the full ``timeout × endpoints`` budget. + """ for url in self._start_urls: - rec = _post_profile(url) + rec = await asyncio.to_thread(_post_profile, url) if rec["status"] == 200: logger.info("Profile start: %s -> 200 OK", url) else: @@ -182,10 +188,11 @@ def start(self) -> None: ) self._starts.append(rec) - def stop(self, completed_normally: bool) -> None: + async def stop(self, completed_normally: bool) -> None: """Fire /stop_profile for every start that returned 200. Unifies the clean phase-end path and the abort path — both call this. + POSTs run in worker threads (see ``start``). """ if not self._starts: return @@ -193,7 +200,7 @@ def stop(self, completed_normally: bool) -> None: for i, start_rec in enumerate(self._starts): if start_rec["status"] != 200 or i >= len(self._stop_urls): continue - rec = _post_profile(self._stop_urls[i]) + rec = await asyncio.to_thread(_post_profile, self._stop_urls[i]) rec["stop_reason"] = stop_reason if rec["status"] == 200: logger.info("Profile stop: %s -> 200 OK", self._stop_urls[i]) diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index 2177e1e6c..6234732dc 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -47,8 +47,9 @@ class SigintGovernor: KeyboardInterrupt and abort teardown half-way. Semantics: - - ^C with no live run task (sync setup): nothing to stop gracefully — - raise KeyboardInterrupt immediately (default behavior, exit 130). + - ^C with no live run (sync setup, finalization after the loop returned, + between audit phases): nothing to stop gracefully — raise + KeyboardInterrupt immediately (default behavior, exit 130). - First ^C: graceful — ``session.stop()``; the stopped run publishes INTERRUPTED+ENDED, services drain (including the metrics-tokenization backlog), artifacts land as state=interrupted, then ``run_benchmark`` @@ -109,7 +110,16 @@ def __call__(self, signum: int, frame: types.FrameType | None) -> None: return raise KeyboardInterrupt self.interrupted = True - if self._session is None or self._loop is None: + if ( + self._session is None + or self._task is None + or self._task.done() + or self._loop is None + or not self._loop.is_running() + ): + # No live run to stop gracefully. call_soon_threadsafe on a + # stopped loop would queue session.stop and never run it — + # silently swallowing the ^C. raise KeyboardInterrupt logger.warning( "SIGINT received: stopping benchmark gracefully (^C again to force)" diff --git a/src/inference_endpoint/load_generator/session.py b/src/inference_endpoint/load_generator/session.py index 5f99e39ad..5da8f56f6 100644 --- a/src/inference_endpoint/load_generator/session.py +++ b/src/inference_endpoint/load_generator/session.py @@ -24,7 +24,7 @@ import logging import time import uuid -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from enum import Enum from typing import Any, Protocol @@ -425,7 +425,7 @@ def stop_current_phase(self) -> None: async def run( self, phases: list[PhaseConfig], - on_phase_start: Callable[[PhaseConfig], None] | None = None, + on_phase_start: Callable[[PhaseConfig], Awaitable[None]] | None = None, ) -> SessionResult: """Run all benchmark phases sequentially. @@ -442,7 +442,7 @@ async def run( if self._stop_requested: break if on_phase_start is not None: - on_phase_start(phase) + await on_phase_start(phase) result = await self._run_phase(phase) if result is not None: phase_results.append(result) diff --git a/src/inference_endpoint/profiling/line_profiler.py b/src/inference_endpoint/profiling/line_profiler.py index f27d29db5..67085e55a 100644 --- a/src/inference_endpoint/profiling/line_profiler.py +++ b/src/inference_endpoint/profiling/line_profiler.py @@ -80,18 +80,6 @@ def __init__(self): self.profiler = LineProfiler() self.profiler.enable() - def _cleanup(self): - """Print pending stats and tear the profiler down. Idempotent.""" - if not self.profiler or self._stats_printed or not self.profiler.functions: - self._teardown_profiler() - return - - with contextlib.suppress(Exception): - self.pause() - self._print_stats_to_destination() - self._stats_printed = True - self._teardown_profiler() - def _print_stats_to_destination(self): """Print stats to configured output destination.""" pid = os.getpid() @@ -165,10 +153,21 @@ def pause(self): pass # Already torn down def shutdown(self): - """Print pending stats and tear down. Safe to call multiple times.""" - if self._stats_printed: + """Print pending stats and tear down. Safe to call multiple times. + + Teardown runs unconditionally: ``_stats_printed`` only suppresses a + duplicate dump (e.g. ``print_stats()`` already ran), and a failing + output destination must still leave the C profiler disabled. + """ + if not self.profiler: return - self._cleanup() + try: + if not self._stats_printed and self.profiler.functions: + with contextlib.suppress(Exception): + self.pause() + self._print_stats_to_destination() + finally: + self._teardown_profiler() def is_enabled(self) -> bool: """Check if profiling is currently enabled.""" diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index 79f422b6c..e3caaf66b 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -216,7 +216,7 @@ def test_metrics_drain_timeout_fails_run(mock_http_echo_server, tmp_path): ), ) - with pytest.raises(ExecutionError, match="Metrics drain timed out"): + with pytest.raises(ExecutionError, match="Metrics tokenization did not finish"): run_benchmark(config, TestMode.PERF) snapshot = _read_final_snapshot(report_dir) diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index cfa455c26..cbaa90146 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -3006,7 +3006,8 @@ def test_write_section_and_json_roundtrip(self): assert json.loads(json.dumps(payload))["engine"] == "vllm" @pytest.mark.unit - def test_controller_start_then_stop_maps_indices(self): + @pytest.mark.asyncio + async def test_controller_start_then_stop_maps_indices(self): """start() posts each /start_profile; stop() posts /stop_profile only for the starts that returned 200, mapped by the same index, tagging stop_reason.""" @@ -3028,8 +3029,8 @@ def _fake_post(url): ctrl = ProfileController( ProfilerEngine.VLLM, ["http://a/v1", "http://b/v1"], None ) - ctrl.start() - ctrl.stop(completed_normally=True) + await ctrl.start() + await ctrl.stop(completed_normally=True) payload = ctrl.payload() assert payload["engine"] == "vllm" @@ -3042,7 +3043,8 @@ def _fake_post(url): assert payload["stops"][0]["stop_reason"] == "phase_end" @pytest.mark.unit - def test_controller_stop_reason_abort_when_not_completed(self): + @pytest.mark.asyncio + async def test_controller_stop_reason_abort_when_not_completed(self): with patch( "inference_endpoint.commands.benchmark.profiling._post_profile", side_effect=lambda url: { @@ -3054,26 +3056,28 @@ def test_controller_stop_reason_abort_when_not_completed(self): }, ): ctrl = ProfileController(ProfilerEngine.VLLM, ["http://a/v1"], None) - ctrl.start() - ctrl.stop(completed_normally=False) + await ctrl.start() + await ctrl.stop(completed_normally=False) assert ctrl.payload()["stops"][0]["stop_reason"] == "abort" @pytest.mark.unit - def test_controller_disabled_is_noop(self): + @pytest.mark.asyncio + async def test_controller_disabled_is_noop(self): """engine=None → no URLs derived, start/stop do nothing, payload is None.""" ctrl = ProfileController(None, ["http://a/v1"], None) - ctrl.start() - ctrl.stop(completed_normally=True) + await ctrl.start() + await ctrl.stop(completed_normally=True) assert ctrl.payload() is None @pytest.mark.unit - def test_controller_stop_without_start_posts_nothing(self): + @pytest.mark.asyncio + async def test_controller_stop_without_start_posts_nothing(self): """stop() before any start() records nothing (empty _starts, early return).""" with patch( "inference_endpoint.commands.benchmark.profiling._post_profile", ) as mock_post: ctrl = ProfileController(ProfilerEngine.VLLM, ["http://a/v1"], None) - ctrl.stop(completed_normally=True) + await ctrl.stop(completed_normally=True) mock_post.assert_not_called() assert ctrl.payload()["stops"] == [] diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py new file mode 100644 index 000000000..591db62ce --- /dev/null +++ b/tests/unit/commands/test_watchdog.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""SigintGovernor state machine: graceful vs force vs no-live-run paths.""" + +from __future__ import annotations + +import asyncio +import signal +from unittest.mock import MagicMock + +import pytest +from inference_endpoint.commands.benchmark.watchdog import SigintGovernor + + +def _fire(gov: SigintGovernor) -> None: + gov(signal.SIGINT, None) + + +def _distinct_fire(gov: SigintGovernor) -> None: + """A ^C outside the duplicate-delivery window (a deliberate press).""" + gov._last_accepted_monotonic = float("-inf") + _fire(gov) + + +@pytest.mark.unit +class TestSigintGovernor: + def test_unbound_first_sigint_raises_keyboard_interrupt(self): + gov = SigintGovernor() + with pytest.raises(KeyboardInterrupt): + _fire(gov) + assert gov.interrupted + assert not gov.forced + + @pytest.mark.asyncio + async def test_live_run_first_sigint_stops_session_gracefully(self): + gov = SigintGovernor() + session = MagicMock() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session) + + _fire(gov) + await asyncio.sleep(0) # let the queued call_soon_threadsafe run + + assert gov.interrupted + assert not gov.forced + session.stop.assert_called_once() + + def test_first_sigint_after_loop_returned_raises_immediately(self): + """A ^C during sync finalization must not be swallowed. + + After ``run_until_complete`` returns, the session/task/loop stay + bound but the loop is stopped — ``call_soon_threadsafe`` would queue + ``session.stop`` on it and never run it. + """ + gov = SigintGovernor() + session = MagicMock() + + async def run_phase() -> None: + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session) + + asyncio.run(run_phase()) + + with pytest.raises(KeyboardInterrupt): + _fire(gov) + assert gov.interrupted + session.stop.assert_not_called() + + def test_second_distinct_sigint_after_loop_returned_raises(self): + """The force path with a finished task escalates to KeyboardInterrupt.""" + gov = SigintGovernor() + session = MagicMock() + + async def run_phase() -> None: + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session) + + asyncio.run(run_phase()) + + with pytest.raises(KeyboardInterrupt): + _fire(gov) + with pytest.raises(KeyboardInterrupt): + _distinct_fire(gov) + assert gov.forced + + @pytest.mark.asyncio + async def test_duplicate_delivery_within_window_is_dropped(self): + """One keystroke forwarded by a runner (uv run) must count once.""" + gov = SigintGovernor() + session = MagicMock() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session) + + _fire(gov) + _fire(gov) # forwarded duplicate, inside the window + await asyncio.sleep(0) + + assert gov.interrupted + assert not gov.forced + session.stop.assert_called_once() + + @pytest.mark.asyncio + async def test_second_distinct_sigint_cancels_live_run_task(self): + gov = SigintGovernor() + session = MagicMock() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session) + + _fire(gov) + _distinct_fire(gov) + with pytest.raises(asyncio.CancelledError): + await asyncio.sleep(5) + + assert gov.forced diff --git a/tests/unit/load_generator/test_async_session.py b/tests/unit/load_generator/test_async_session.py index a0fdbef08..a0446c7d5 100644 --- a/tests/unit/load_generator/test_async_session.py +++ b/tests/unit/load_generator/test_async_session.py @@ -21,6 +21,7 @@ import random import pytest +from inference_endpoint.commands.benchmark.watchdog import PerfPhaseTimeout from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import LoadPattern, LoadPatternType from inference_endpoint.core.record import ( @@ -619,6 +620,65 @@ async def test_stop_current_phase_advances_to_accuracy(self): # The session-wide stop flag was never set. assert session._stop_requested is False + @pytest.mark.asyncio + async def test_async_phase_start_hook_awaited_before_issuing(self): + """``on_phase_start`` is awaited to completion before the phase issues.""" + loop = asyncio.get_running_loop() + issuer = FakeIssuer() + issuer._loop = loop + publisher = FakePublisher() + session = BenchmarkSession(issuer, publisher, loop) + + hook_done = False + + async def hook(phase: PhaseConfig) -> None: + nonlocal hook_done + await asyncio.sleep(0.01) + assert issuer._issued == [] + hook_done = True + + phases = [PhaseConfig("perf", _make_settings(n_samples=3), FakeDataset(3))] + result = await session.run(phases, on_phase_start=hook) + + assert hook_done + assert result.perf_results[0].issued_count == 3 + + @pytest.mark.asyncio + async def test_perf_cap_armed_after_slow_hook_still_bounds_phase(self): + """A perf cap shorter than a slow phase-start hook must still fire. + + Mirrors execute.py's ``_on_phase_start`` ordering: the one-shot + ``PerfPhaseTimeout`` is armed AFTER the hook's await (profile arming). + Armed before it, a cap shorter than the hook delay fires while the + hook is still awaiting; ``_run_phase`` then clears the phase-stop + flag at entry, the fire is erased, and the phase runs uncapped. + """ + loop = asyncio.get_running_loop() + issuer = FakeIssuer() + issuer._loop = loop + publisher = FakePublisher() + session = BenchmarkSession(issuer, publisher, loop) + + perf_timeout = PerfPhaseTimeout(loop, 30, session.stop_current_phase) + + async def hook(phase: PhaseConfig) -> None: + await asyncio.sleep(0.05) # slow profile arming, longer than the cap + perf_timeout.on_phase_start(phase.phase_type) + + phases = [ + PhaseConfig( + "perf", + _make_settings(n_samples=100_000, max_duration_ms=10_000), + FakeDataset(100), + PhaseType.PERFORMANCE, + ), + ] + result = await asyncio.wait_for( + session.run(phases, on_phase_start=hook), timeout=10.0 + ) + + assert result.perf_results[0].issued_count < 100_000 + @pytest.mark.asyncio async def test_stop_current_phase_unblocks_unbounded_drain(self): """The per-phase cap must break an in-progress unbounded drain wait. diff --git a/tests/unit/test_profiler.py b/tests/unit/test_profiler.py index 9f98af68c..8fe9c1c01 100644 --- a/tests/unit/test_profiler.py +++ b/tests/unit/test_profiler.py @@ -223,3 +223,39 @@ def test_shutdown_handles_multiple_calls(self): state.shutdown() state.shutdown() state.shutdown() + + def test_shutdown_after_print_stats_still_tears_down(self): + """print_stats() marks stats printed; shutdown() must still teardown.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + line_profiler.ProfilerState._instance = None + state = line_profiler.ProfilerState() + + @state.profile + def traced(x): + return x + 1 + + traced(1) + state.print_stats(stream=io.StringIO()) + assert state._stats_printed is True + + state.shutdown() + assert state.profiler is None + + def test_shutdown_tears_down_when_output_destination_fails(self): + """A failing stats dump must still leave the C profiler disabled.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + line_profiler.ProfilerState._instance = None + state = line_profiler.ProfilerState() + + @state.profile + def traced(x): + return x + 1 + + traced(1) + with mock.patch.object( + state, + "_print_stats_to_destination", + side_effect=OSError("disk full"), + ): + state.shutdown() + assert state.profiler is None From d23f05a0afa9779c8d6c0da0c3dfccbf03e632a6 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 13:39:43 -0700 Subject: [PATCH 29/45] test: consolidate interrupt/timeout suites; exhaustive SIGINT transition matrix Test-quality pass over the PR's test surface: - SigintGovernor: replace overlapping named scenario tests with one exhaustive delivery-sequence matrix (every bind-state x distinct/dup sequence up to length 3, against a dedicated child task), keeping only the finished-loop cases the matrix cannot express. PerfPhaseTimeout tests move next to the governor and run against the real event loop (cap fires, accuracy start disarms, never-armed cases parametrized, idempotent cancel) instead of a fake-loop scheduling recorder. - test_sigint.py: launch/readiness/teardown/leftover-scan boilerplate extracted into helpers; four distinct scenarios stay. - test_run_timeout.py: shared _make_config builder + big-prompts dataset writer replace five hand-rolled config blocks. - test_schema.py: drop max_duration 0/negative rejection duplicates (covered parametrized in test_timeouts.py, same validator). - test_benchmark.py: audit-dispatch tests get a shared config-double helper and hoisted module imports; the two audit-FAIL tests collapse into one parametrized case; class renamed TestRunBenchmarkAuditDispatch. - test_profiler.py: file-level unit pytestmark (was invisible to -m unit), enabled_profiler fixture replaces repeated env/singleton setup, singleton restored after every test; drop the conditional no-op prefix test. - line_profiler._teardown_profiler tolerates an already-disabled profiler (line_profiler raises ValueError on double sys.monitoring release) so shutdown teardown is truly unconditional. --- .../profiling/line_profiler.py | 8 +- .../integration/commands/test_run_timeout.py | 190 +++++++------- tests/integration/commands/test_sigint.py | 245 ++++++++---------- tests/unit/commands/test_benchmark.py | 227 ++++------------ tests/unit/commands/test_watchdog.py | 172 +++++++++--- tests/unit/config/test_schema.py | 22 -- tests/unit/test_profiler.py | 221 ++++++---------- 7 files changed, 456 insertions(+), 629 deletions(-) diff --git a/src/inference_endpoint/profiling/line_profiler.py b/src/inference_endpoint/profiling/line_profiler.py index 67085e55a..910eb66c5 100644 --- a/src/inference_endpoint/profiling/line_profiler.py +++ b/src/inference_endpoint/profiling/line_profiler.py @@ -97,7 +97,13 @@ def _teardown_profiler(self): if not self.profiler: return - self.profiler.disable() + try: + self.profiler.disable() + except ValueError: + # Already disabled: line_profiler releases its sys.monitoring + # tool id on disable, and a second disable (e.g. after a stats + # snapshot) raises. The teardown below must still run. + pass self.profiler.functions.clear() self.profiler.enable_count = 0 self.profiler = None diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index e3caaf66b..c96134199 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -68,29 +68,70 @@ def _read_result_summary(report_dir: Path) -> dict: return json.loads((report_dir / "performance" / "result_summary.json").read_text()) +def _make_config( + endpoint_url: str, + dataset_path: Path, + report_dir: Path, + *, + test_type: TestType = TestType.OFFLINE, + model_name: str = "echo-server", + load_pattern: LoadPattern | None = None, + runtime: RuntimeConfig | None = None, + timeouts: Timeouts | None = None, + metrics_tokenizer_workers: int | None = None, +) -> BenchmarkConfig: + settings_kwargs: dict = { + "load_pattern": load_pattern + or LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), + "client": _FAST_CLIENT, + "warmup": WarmupConfig(enabled=False), + } + if runtime is not None: + settings_kwargs["runtime"] = runtime + if timeouts is not None: + settings_kwargs["timeouts"] = timeouts + if metrics_tokenizer_workers is not None: + settings_kwargs["metrics_tokenizer_workers"] = metrics_tokenizer_workers + return BenchmarkConfig( + type=test_type, + endpoint_config=EndpointConfig(endpoints=[endpoint_url]), + model_params=ModelParams(name=model_name, streaming=StreamingMode.OFF), + datasets=[Dataset(path=str(dataset_path), type=DatasetType.PERFORMANCE)], + report_dir=report_dir, + settings=Settings(**settings_kwargs), + ) + + +def _write_big_prompts_dataset(tmp_path: Path) -> Path: + """~25 MB of prompt text; the echo server doubles it into OSL, so the + end-of-run drain has ~50M characters to tokenize — far more than any + small deadline allows on any hardware.""" + dataset_path = tmp_path / "big_prompts.jsonl" + prompt = "lorem ipsum " * 21_000 # ~250 KB per sample + with dataset_path.open("w") as f: + for i in range(100): + f.write(json.dumps({"prompt": f"{i} {prompt}"}) + "\n") + return dataset_path + + @pytest.mark.integration def test_run_timeout_produces_interrupted_report( mock_http_echo_server, ds_dataset_path, tmp_path ): """run_timeout_s firing mid-run aborts with an INTERRUPTED report.""" - config = BenchmarkConfig( - type=TestType.ONLINE, - endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), - model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), - datasets=[Dataset(path=str(ds_dataset_path), type=DatasetType.PERFORMANCE)], - report_dir=tmp_path, - settings=Settings( - load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), - client=_FAST_CLIENT, - # 600 samples at 5 QPS is a ~120 s workload, so only the watchdog - # can end the run. The budget must comfortably exceed service + - # worker startup (a fire before the session exists aborts the - # launch instead, without mid-run artifacts — a different path, - # covered by test_run_timeout_during_service_launch_aborts_promptly). - runtime=RuntimeConfig(n_samples_to_issue=600), - timeouts=Timeouts(run_timeout_s=6.0), - warmup=WarmupConfig(enabled=False), - ), + config = _make_config( + mock_http_echo_server.url, + ds_dataset_path, + tmp_path, + test_type=TestType.ONLINE, + load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), + # 600 samples at 5 QPS is a ~120 s workload, so only the watchdog + # can end the run. The budget must comfortably exceed service + + # worker startup (a fire before the session exists aborts the + # launch instead, without mid-run artifacts — a different path, + # covered by test_run_timeout_during_service_launch_aborts_promptly). + runtime=RuntimeConfig(n_samples_to_issue=600), + timeouts=Timeouts(run_timeout_s=6.0), ) with pytest.raises(ExecutionError, match="Run timeout"): @@ -110,18 +151,11 @@ def test_generous_run_timeout_completes_normally( ): """A run_timeout_s far above the workload length never fires: the run finishes cleanly and publishes a COMPLETE report.""" - config = BenchmarkConfig( - type=TestType.OFFLINE, - endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), - model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), - datasets=[Dataset(path=str(ds_dataset_path), type=DatasetType.PERFORMANCE)], - report_dir=tmp_path, - settings=Settings( - load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), - client=_FAST_CLIENT, - timeouts=Timeouts(run_timeout_s=300.0), - warmup=WarmupConfig(enabled=False), - ), + config = _make_config( + mock_http_echo_server.url, + ds_dataset_path, + tmp_path, + timeouts=Timeouts(run_timeout_s=300.0), ) run_benchmark(config, TestMode.PERF) # must not raise @@ -143,35 +177,19 @@ def test_run_timeout_during_metrics_drain_interrupts(mock_http_echo_server, tmp_ aggregator drains, SIGTERM it, and surface the run as INTERRUPTED with a non-zero exit. """ - # ~25 MB of prompt text; the echo server doubles it into OSL, so the - # drain has ~50M characters to tokenize — far more than run_timeout_s - # allows on any hardware. - dataset_path = tmp_path / "big_prompts.jsonl" - prompt = "lorem ipsum " * 21_000 # ~250 KB per sample - with dataset_path.open("w") as f: - for i in range(100): - f.write(json.dumps({"prompt": f"{i} {prompt}"}) + "\n") - - report_dir = tmp_path / "report" - config = BenchmarkConfig( - type=TestType.OFFLINE, - endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), - model_params=ModelParams( - name=str(_CHAR_TOKENIZER_DIR), streaming=StreamingMode.OFF - ), - datasets=[Dataset(path=str(dataset_path), type=DatasetType.PERFORMANCE)], - report_dir=report_dir, - settings=Settings( - load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), - client=_FAST_CLIENT, - # Defer every ISL/OSL tokenization to the end-of-run drain. - metrics_tokenizer_workers=0, - # metrics_drain_timeout_s stays None (unlimited): only the - # run watchdog can end the drain. - timeouts=Timeouts(run_timeout_s=2.5), - warmup=WarmupConfig(enabled=False), - ), + dataset_path = _write_big_prompts_dataset(tmp_path) + config = _make_config( + mock_http_echo_server.url, + dataset_path, + tmp_path / "report", + model_name=str(_CHAR_TOKENIZER_DIR), + # Defer every ISL/OSL tokenization to the end-of-run drain. + # metrics_drain_timeout_s stays None (unlimited): only the run + # watchdog can end the drain. + metrics_tokenizer_workers=0, + timeouts=Timeouts(run_timeout_s=2.5), ) + report_dir = tmp_path / "report" with pytest.raises(ExecutionError, match="Run timeout"): run_benchmark(config, TestMode.PERF) @@ -189,32 +207,19 @@ def test_metrics_drain_timeout_fails_run(mock_http_echo_server, tmp_path): with complete: false, and run_benchmark must raise so partial ISL/OSL stats can never look like a clean exit. """ - dataset_path = tmp_path / "big_prompts.jsonl" - prompt = "lorem ipsum " * 21_000 # ~250 KB per sample - with dataset_path.open("w") as f: - for i in range(100): - f.write(json.dumps({"prompt": f"{i} {prompt}"}) + "\n") - - report_dir = tmp_path / "report" - config = BenchmarkConfig( - type=TestType.OFFLINE, - endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), - model_params=ModelParams( - name=str(_CHAR_TOKENIZER_DIR), streaming=StreamingMode.OFF - ), - datasets=[Dataset(path=str(dataset_path), type=DatasetType.PERFORMANCE)], - report_dir=report_dir, - settings=Settings( - load_pattern=LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), - client=_FAST_CLIENT, - # Defer every ISL/OSL tokenization to the end-of-run drain, then - # give the drain a budget far below the ~50M-char backlog. No run - # watchdog: the drain deadline itself must fail the run. - metrics_tokenizer_workers=0, - timeouts=Timeouts(metrics_drain_timeout_s=1.0), - warmup=WarmupConfig(enabled=False), - ), + dataset_path = _write_big_prompts_dataset(tmp_path) + config = _make_config( + mock_http_echo_server.url, + dataset_path, + tmp_path / "report", + model_name=str(_CHAR_TOKENIZER_DIR), + # Defer every ISL/OSL tokenization to the end-of-run drain, then + # give the drain a budget far below the ~50M-char backlog. No run + # watchdog: the drain deadline itself must fail the run. + metrics_tokenizer_workers=0, + timeouts=Timeouts(metrics_drain_timeout_s=1.0), ) + report_dir = tmp_path / "report" with pytest.raises(ExecutionError, match="Metrics tokenization did not finish"): run_benchmark(config, TestMode.PERF) @@ -238,18 +243,13 @@ def test_run_timeout_during_service_launch_aborts_promptly( of running out their own readiness timeouts, and the abort is attributed to the run timeout (ExecutionError), not to a secondary launch error. """ - config = BenchmarkConfig( - type=TestType.ONLINE, - endpoint_config=EndpointConfig(endpoints=[mock_http_echo_server.url]), - model_params=ModelParams(name="echo-server", streaming=StreamingMode.OFF), - datasets=[Dataset(path=str(ds_dataset_path), type=DatasetType.PERFORMANCE)], - report_dir=tmp_path, - settings=Settings( - load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), - client=_FAST_CLIENT, - runtime=RuntimeConfig(n_samples_to_issue=10), - warmup=WarmupConfig(enabled=False), - ), + config = _make_config( + mock_http_echo_server.url, + ds_dataset_path, + tmp_path, + test_type=TestType.ONLINE, + load_pattern=LoadPattern(type=LoadPatternType.POISSON, target_qps=5), + runtime=RuntimeConfig(n_samples_to_issue=10), ) ctx = setup_benchmark(config, TestMode.PERF) diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index a04300b67..76c6a0ff4 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -13,29 +13,32 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Whole-process Ctrl-C integration test. +"""Whole-process Ctrl-C integration tests. -The one interruption path no unit test can compose: a real +The interruption paths no unit test can compose: a real ``inference-endpoint`` subprocess in its own process group receives SIGINT (exactly what a terminal ^C delivers to the foreground group — parent and -service children alike) mid-run. The contract: +service children alike). The contract, at every delivery point: - exit code 130 (user abort, distinct from failure exit codes 1-4); - artifacts are honest: ``final_snapshot.json`` ``state=interrupted`` (the session's INTERRUPTED marker drives the aggregator's ENDED finalize) and - ``result_summary.json`` ``complete: false``; + ``result_summary.json`` ``complete: false`` — or no artifacts at all, + never a COMPLETE-looking report from an aborted run; - ``events.jsonl`` survives — the event logger ignores the group SIGINT and flushes on the session's terminal ENDED; - no service child outlives the run; - teardown is prompt, not a hang on an unbounded drain. """ +import contextlib import json import os import shutil import signal import subprocess import time +from collections.abc import Iterator from pathlib import Path import pytest @@ -75,79 +78,74 @@ def _write_config(report_dir: Path, endpoint_url: str, config_path: Path) -> Non ) -def _procs_referencing(needle: str) -> list[str]: - """Cmdlines of live processes whose argv mentions ``needle`` (Linux).""" - hits = [] - for pid_dir in Path("/proc").iterdir(): - if not pid_dir.name.isdigit(): - continue - try: - cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\0", b" ") - except OSError: - continue # process exited mid-scan - if needle.encode() in cmdline: - hits.append(cmdline.decode(errors="replace")) - return hits - - -@pytest.mark.integration -def test_sigint_mid_run_exits_130_with_interrupted_artifacts( - mock_http_echo_server, tmp_path -): +def _cli() -> str: cli = shutil.which("inference-endpoint") assert cli is not None, "console script must be installed in the test venv" + return cli - report_dir = tmp_path / "report" - config_path = tmp_path / "bench.yaml" - _write_config(report_dir, mock_http_echo_server.url, config_path) +@contextlib.contextmanager +def _benchmark_proc(argv: list[str]) -> Iterator[subprocess.Popen]: + """A benchmark subprocess in its own group, SIGKILLed on exit if alive.""" proc = subprocess.Popen( - [cli, "benchmark", "from-config", "-c", str(config_path)], + argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, # own process group, like a foreground job ) try: - # The aggregator touches metrics/.ready once its signal handlers are - # registered; the session starts issuing right after service readiness. - ready = report_dir / "metrics" / ".ready" - deadline = time.monotonic() + 60.0 - while not ready.exists(): - assert proc.poll() is None, "benchmark died before services came up" - assert time.monotonic() < deadline, "services never became ready" - time.sleep(0.1) - time.sleep(3.0) # comfortably inside the ~120 s performance phase - - os.killpg(proc.pid, signal.SIGINT) - rc = proc.wait(timeout=60.0) + yield proc finally: if proc.poll() is None: os.killpg(proc.pid, signal.SIGKILL) proc.wait() - assert rc == 130, f"user abort must exit 130, got {rc}" - snapshot = json.loads((report_dir / "metrics" / "final_snapshot.json").read_text()) - assert snapshot["state"] == "interrupted" +def _wait_services_ready( + proc: subprocess.Popen, report_dir: Path, timeout: float = 60.0 +) -> None: + """Block until the aggregator touches metrics/.ready (handlers installed); + the session starts issuing right after service readiness.""" + ready = report_dir / "metrics" / ".ready" + deadline = time.monotonic() + timeout + while not ready.exists(): + assert proc.poll() is None, "benchmark died before services came up" + assert time.monotonic() < deadline, "services never became ready" + time.sleep(0.1) - summary = json.loads( - (report_dir / "performance" / "result_summary.json").read_text() - ) - assert summary["complete"] is False - # The event logger must survive the group SIGINT and flush on ENDED. - assert ( - report_dir / "events.jsonl" - ).exists(), "events.jsonl missing — event logger died on ^C instead of flushing" +def _procs_referencing(needle: str) -> list[str]: + """Cmdlines of live processes whose argv mentions ``needle`` (Linux).""" + hits = [] + for pid_dir in Path("/proc").iterdir(): + if not pid_dir.name.isdigit(): + continue + try: + cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\0", b" ") + except OSError: + continue # process exited mid-scan + if needle.encode() in cmdline: + hits.append(cmdline.decode(errors="replace")) + return hits + - # No aggregator/event-logger child may outlive the run. +def _assert_no_leftover_children(report_dir: Path, what: str) -> None: deadline = time.monotonic() + 10.0 while time.monotonic() < deadline: leftovers = _procs_referencing(str(report_dir)) if not leftovers: - break + return time.sleep(0.2) - assert not leftovers, f"service children outlived the run: {leftovers}" + raise AssertionError(f"service children outlived the {what}: {leftovers}") + + +def _assert_interrupted_artifacts(report_dir: Path) -> None: + snapshot = json.loads((report_dir / "metrics" / "final_snapshot.json").read_text()) + assert snapshot["state"] == "interrupted" + summary = json.loads( + (report_dir / "performance" / "result_summary.json").read_text() + ) + assert summary["complete"] is False def _pid_of_child(needle: str, extra: str) -> int | None: @@ -164,6 +162,31 @@ def _pid_of_child(needle: str, extra: str) -> int | None: return None +@pytest.mark.integration +def test_sigint_mid_run_exits_130_with_interrupted_artifacts( + mock_http_echo_server, tmp_path +): + report_dir = tmp_path / "report" + config_path = tmp_path / "bench.yaml" + _write_config(report_dir, mock_http_echo_server.url, config_path) + + with _benchmark_proc( + [_cli(), "benchmark", "from-config", "-c", str(config_path)] + ) as proc: + _wait_services_ready(proc, report_dir) + time.sleep(3.0) # comfortably inside the ~120 s performance phase + os.killpg(proc.pid, signal.SIGINT) + rc = proc.wait(timeout=60.0) + + assert rc == 130, f"user abort must exit 130, got {rc}" + _assert_interrupted_artifacts(report_dir) + # The event logger must survive the group SIGINT and flush on ENDED. + assert ( + report_dir / "events.jsonl" + ).exists(), "events.jsonl missing — event logger died on ^C instead of flushing" + _assert_no_leftover_children(report_dir, "run") + + @pytest.mark.integration def test_second_sigint_force_quits_immediately(mock_http_echo_server, tmp_path): """Second ^C abandons a wedged metrics drain and exits 130 promptly. @@ -175,45 +198,31 @@ def test_second_sigint_force_quits_immediately(mock_http_echo_server, tmp_path): for the stopped aggregator; the second ^C must SIGKILL the children and exit 130 within seconds. """ - cli = shutil.which("inference-endpoint") - assert cli is not None, "console script must be installed in the test venv" - report_dir = tmp_path / "report" config_path = tmp_path / "bench.yaml" _write_config(report_dir, mock_http_echo_server.url, config_path) - proc = subprocess.Popen( - [cli, "benchmark", "from-config", "-c", str(config_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) agg_pid: int | None = None try: - ready = report_dir / "metrics" / ".ready" - deadline = time.monotonic() + 60.0 - while not ready.exists(): - assert proc.poll() is None, "benchmark died before services came up" - assert time.monotonic() < deadline, "services never became ready" - time.sleep(0.1) - time.sleep(3.0) # comfortably inside the ~120 s performance phase - - agg_pid = _pid_of_child("metrics_aggregator", str(report_dir)) - assert agg_pid is not None, "aggregator child not found" - os.kill(agg_pid, signal.SIGSTOP) # wedge the drain - - os.kill(proc.pid, signal.SIGINT) - time.sleep(2.0) # graceful path engaged; drain parked on the wedge - assert proc.poll() is None, "first ^C must keep waiting on the drain" - - os.kill(proc.pid, signal.SIGINT) - start = time.monotonic() - rc = proc.wait(timeout=15.0) - force_quit_latency = time.monotonic() - start + with _benchmark_proc( + [_cli(), "benchmark", "from-config", "-c", str(config_path)] + ) as proc: + _wait_services_ready(proc, report_dir) + time.sleep(3.0) # comfortably inside the ~120 s performance phase + + agg_pid = _pid_of_child("metrics_aggregator", str(report_dir)) + assert agg_pid is not None, "aggregator child not found" + os.kill(agg_pid, signal.SIGSTOP) # wedge the drain + + os.kill(proc.pid, signal.SIGINT) + time.sleep(2.0) # graceful path engaged; drain parked on the wedge + assert proc.poll() is None, "first ^C must keep waiting on the drain" + + os.kill(proc.pid, signal.SIGINT) + start = time.monotonic() + rc = proc.wait(timeout=15.0) + force_quit_latency = time.monotonic() - start finally: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait() if agg_pid is not None: try: os.kill(agg_pid, signal.SIGKILL) # SIGKILL reaps stopped procs @@ -224,15 +233,7 @@ def test_second_sigint_force_quits_immediately(mock_http_echo_server, tmp_path): assert ( force_quit_latency < 10.0 ), f"force quit took {force_quit_latency:.1f}s — the drain was not abandoned" - - # SIGKILLed children must not outlive the run. - deadline = time.monotonic() + 10.0 - while time.monotonic() < deadline: - leftovers = _procs_referencing(str(report_dir)) - if not leftovers: - break - time.sleep(0.2) - assert not leftovers, f"service children outlived the force quit: {leftovers}" + _assert_no_leftover_children(report_dir, "force quit") @pytest.mark.integration @@ -242,37 +243,19 @@ def test_sigint_before_session_exits_130(mock_http_echo_server, tmp_path): No session is bound yet, so the governor falls back to an immediate KeyboardInterrupt — the run must not hang or exit 0. """ - cli = shutil.which("inference-endpoint") - assert cli is not None, "console script must be installed in the test venv" - report_dir = tmp_path / "report" config_path = tmp_path / "bench.yaml" _write_config(report_dir, mock_http_echo_server.url, config_path) - proc = subprocess.Popen( - [cli, "benchmark", "from-config", "-c", str(config_path)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - try: + with _benchmark_proc( + [_cli(), "benchmark", "from-config", "-c", str(config_path)] + ) as proc: time.sleep(1.5) # interpreter up, setup underway; services not ready os.killpg(proc.pid, signal.SIGINT) rc = proc.wait(timeout=30.0) - finally: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait() assert rc == 130, f"pre-session ^C must exit 130, got {rc}" - - deadline = time.monotonic() + 10.0 - while time.monotonic() < deadline: - leftovers = _procs_referencing(str(report_dir)) - if not leftovers: - break - time.sleep(0.2) - assert not leftovers, f"children outlived the aborted run: {leftovers}" + _assert_no_leftover_children(report_dir, "aborted run") @pytest.mark.integration @@ -292,7 +275,7 @@ def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp config_path = tmp_path / "bench.yaml" _write_config(report_dir, mock_http_echo_server.url, config_path) - proc = subprocess.Popen( + with _benchmark_proc( [ uv, "run", @@ -301,33 +284,13 @@ def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp "from-config", "-c", str(config_path), - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - try: - ready = report_dir / "metrics" / ".ready" - deadline = time.monotonic() + 90.0 - while not ready.exists(): - assert proc.poll() is None, "benchmark died before services came up" - assert time.monotonic() < deadline, "services never became ready" - time.sleep(0.1) + ] + ) as proc: + _wait_services_ready(proc, report_dir, timeout=90.0) time.sleep(3.0) - os.killpg(proc.pid, signal.SIGINT) # one keystroke: group + uv forward rc = proc.wait(timeout=60.0) - finally: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait() assert rc == 130, f"user abort must exit 130, got {rc}" - # The graceful path writes the report; a force quit would have skipped it. - summary = json.loads( - (report_dir / "performance" / "result_summary.json").read_text() - ) - assert summary["complete"] is False - snapshot = json.loads((report_dir / "metrics" / "final_snapshot.json").read_text()) - assert snapshot["state"] == "interrupted" + _assert_interrupted_artifacts(report_dir) diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index cbaa90146..a7f590298 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -27,6 +27,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from urllib import error as urllib_error +import inference_endpoint.commands.benchmark.cli as cli_mod import inference_endpoint.commands.benchmark.execute as execute_mod import pandas as pd import pytest @@ -55,7 +56,6 @@ _render_profile_status, write_profiling_section, ) -from inference_endpoint.commands.benchmark.watchdog import PerfPhaseTimeout from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( AgenticInferenceConfig, @@ -94,6 +94,7 @@ SWEBenchScorer, ) from inference_endpoint.exceptions import ( + CLIError, ExecutionError, InputValidationError, SetupError, @@ -2749,93 +2750,6 @@ def test_report_config_redacts_secrets_without_mutating_runtime( ) -class _FakeTimerHandle: - def __init__(self) -> None: - self.cancelled = False - - def cancel(self) -> None: - self.cancelled = True - - -class _FakeLoop: - """Minimal event loop stub recording call_later scheduling.""" - - def __init__(self) -> None: - self.scheduled: list[tuple[float, object, _FakeTimerHandle]] = [] - - def call_later(self, delay, callback): - handle = _FakeTimerHandle() - self.scheduled.append((delay, callback, handle)) - return handle - - -class TestPerfPhaseTimeout: - """The max_duration_ms cap must bound only the performance phase and never - truncate a subsequent accuracy phase (regression: a combined perf+accuracy - run was guillotined mid-accuracy because the perf timer was never cancelled). - """ - - @pytest.mark.unit - def test_armed_on_performance_phase(self): - loop = _FakeLoop() - fired: list[bool] = [] - timeout = PerfPhaseTimeout(loop, 4000, lambda: fired.append(True)) - - timeout.on_phase_start(PhaseType.PERFORMANCE) - - assert len(loop.scheduled) == 1 - delay, callback, handle = loop.scheduled[0] - assert delay == pytest.approx(4.0) - assert handle.cancelled is False - callback() - assert fired == [True] - - @pytest.mark.unit - def test_cancelled_when_accuracy_phase_starts(self): - loop = _FakeLoop() - timeout = PerfPhaseTimeout(loop, 4000, lambda: None) - - timeout.on_phase_start(PhaseType.PERFORMANCE) - perf_handle = loop.scheduled[0][2] - timeout.on_phase_start(PhaseType.ACCURACY) - - assert perf_handle.cancelled is True - # No new timer armed for the accuracy phase. - assert len(loop.scheduled) == 1 - - @pytest.mark.unit - def test_not_armed_without_max_duration(self): - loop = _FakeLoop() - timeout = PerfPhaseTimeout(loop, None, lambda: None) - - timeout.on_phase_start(PhaseType.PERFORMANCE) - - assert loop.scheduled == [] - - @pytest.mark.unit - def test_not_armed_for_non_performance_phase(self): - loop = _FakeLoop() - timeout = PerfPhaseTimeout(loop, 4000, lambda: None) - - timeout.on_phase_start(PhaseType.WARMUP) - timeout.on_phase_start(PhaseType.ACCURACY) - - assert loop.scheduled == [] - - @pytest.mark.unit - def test_cancel_is_idempotent(self): - loop = _FakeLoop() - timeout = PerfPhaseTimeout(loop, 4000, lambda: None) - - timeout.cancel() # no handle yet — must not raise - timeout.on_phase_start(PhaseType.PERFORMANCE) - handle = loop.scheduled[0][2] - timeout.cancel() - timeout.cancel() - - assert handle.cancelled is True - - class TestSetupBenchmarkExternalSampleCountLogging: """setup_benchmark logs declared external counts for self-contained scorers.""" @@ -3266,28 +3180,41 @@ class TestLoadDatasetsGenerationConfigOverrideCompletions(_OverrideTestBase): max_tokens_key = "max_tokens" -class TestRunBenchmarkInterrupt: +def _audit_cli_config(tmp_path, *, only: bool) -> MagicMock: + """A config double for cli._run with an audit: block configured.""" + config = MagicMock() + config.datasets = [object()] # non-empty → _run skips CLI dataset injection + config.audit = MagicMock(only=only) + config.report_dir = str(tmp_path) + config.with_updates.return_value = config + return config + + +def _failing_audit(cfg, base_report_dir): + result = MagicMock() + result.passed = False + result.test_id = "output_caching_test" + result.details = {"reason": "caching detected"} + return result + + +class TestRunBenchmarkAuditDispatch: + """cli._run's benchmark→audit orchestration (upstream MLPerf order).""" + @pytest.mark.unit def test_keyboard_interrupt_skips_audit(self, monkeypatch, tmp_path): """A Ctrl-C during the main run must not start the audit.""" - from inference_endpoint.commands.benchmark import cli - from inference_endpoint.config.schema import TestMode - - config = MagicMock() - config.datasets = [object()] # non-empty → _run skips CLI dataset injection - config.audit = MagicMock(only=False) # audit IS configured - config.report_dir = str(tmp_path) - config.with_updates.return_value = config + config = _audit_cli_config(tmp_path, only=False) def _interrupt(cfg, mode): raise KeyboardInterrupt - monkeypatch.setattr(cli, "run_benchmark", _interrupt) + monkeypatch.setattr(cli_mod, "run_benchmark", _interrupt) audit_spy = MagicMock() - monkeypatch.setattr(cli, "run_audit", audit_spy) + monkeypatch.setattr(cli_mod, "run_audit", audit_spy) with pytest.raises(KeyboardInterrupt): - cli._run(config, [], TestMode.PERF) + cli_mod._run(config, [], TestMode.PERF) audit_spy.assert_not_called() @pytest.mark.unit @@ -3296,15 +3223,7 @@ def test_main_run_before_audit_against_shared_report_dir( ): """Main run executes before the audit (upstream MLPerf order), sharing one report_dir.""" - from inference_endpoint.commands.benchmark import cli - from inference_endpoint.config.schema import TestMode - - config = MagicMock() - config.datasets = [object()] - config.audit = MagicMock(only=False) - config.report_dir = str(tmp_path) - config.with_updates.return_value = config - + config = _audit_cli_config(tmp_path, only=False) call_order = [] def _run_audit(cfg, base_report_dir): @@ -3317,10 +3236,10 @@ def _run_benchmark(cfg, mode): call_order.append(("benchmark", cfg, mode)) return tmp_path - monkeypatch.setattr(cli, "run_audit", _run_audit) - monkeypatch.setattr(cli, "run_benchmark", _run_benchmark) + monkeypatch.setattr(cli_mod, "run_audit", _run_audit) + monkeypatch.setattr(cli_mod, "run_benchmark", _run_benchmark) - cli._run(config, [], TestMode.PERF) + cli_mod._run(config, [], TestMode.PERF) assert [c[0] for c in call_order] == ["benchmark", "audit"] _, benchmark_cfg, _ = call_order[0] @@ -3329,53 +3248,23 @@ def _run_benchmark(cfg, mode): assert audit_cfg is benchmark_cfg is config @pytest.mark.unit - def test_audit_fail_raises_after_main_run(self, monkeypatch, tmp_path): - """A failing (not crashed) audit raises CLIError; the perf report - already exists because the main run went first.""" - from inference_endpoint.commands.benchmark import cli - from inference_endpoint.config.schema import TestMode - from inference_endpoint.exceptions import CLIError - - config = MagicMock() - config.datasets = [object()] - config.audit = MagicMock(only=False) - config.report_dir = str(tmp_path) - config.with_updates.return_value = config - - call_order = [] - - def _run_audit(cfg, base_report_dir): - call_order.append("audit") - result = MagicMock() - result.passed = False - result.test_id = "output_caching_test" - result.details = {"reason": "caching detected"} - return result - - def _run_benchmark(cfg, mode): - call_order.append("benchmark") - return tmp_path - - monkeypatch.setattr(cli, "run_audit", _run_audit) - monkeypatch.setattr(cli, "run_benchmark", _run_benchmark) + @pytest.mark.parametrize( + "only", [False, True], ids=["after-main-run", "audit-only"] + ) + def test_audit_fail_raises_cli_error(self, monkeypatch, tmp_path, only): + """A failing (not crashed) audit maps to CLIError (exit 1) — both + after a passing main run and standalone via audit.only.""" + config = _audit_cli_config(tmp_path, only=only) + monkeypatch.setattr(cli_mod, "run_audit", _failing_audit) + monkeypatch.setattr(cli_mod, "run_benchmark", MagicMock(return_value=tmp_path)) with pytest.raises(CLIError): - cli._run(config, [], TestMode.PERF) - - assert call_order == ["benchmark", "audit"] + cli_mod._run(config, [], TestMode.PERF) @pytest.mark.unit def test_audit_only_skips_main_run(self, monkeypatch, tmp_path): """audit.only runs the audit standalone — the main benchmark is skipped.""" - from inference_endpoint.commands.benchmark import cli - from inference_endpoint.config.schema import TestMode - - config = MagicMock() - config.datasets = [object()] - config.audit = MagicMock(only=True) - config.report_dir = str(tmp_path) - config.with_updates.return_value = config - + config = _audit_cli_config(tmp_path, only=True) audit_calls = [] def _run_audit(cfg, base_report_dir): @@ -3384,37 +3273,11 @@ def _run_audit(cfg, base_report_dir): result.passed = True return result - monkeypatch.setattr(cli, "run_audit", _run_audit) + monkeypatch.setattr(cli_mod, "run_audit", _run_audit) benchmark_spy = MagicMock() - monkeypatch.setattr(cli, "run_benchmark", benchmark_spy) + monkeypatch.setattr(cli_mod, "run_benchmark", benchmark_spy) - cli._run(config, [], TestMode.PERF) + cli_mod._run(config, [], TestMode.PERF) benchmark_spy.assert_not_called() assert audit_calls == [tmp_path / "audit"] - - @pytest.mark.unit - def test_audit_only_fail_raises(self, monkeypatch, tmp_path): - """audit.only maps a FAIL result to CLIError (exit 1).""" - from inference_endpoint.commands.benchmark import cli - from inference_endpoint.config.schema import TestMode - from inference_endpoint.exceptions import CLIError - - config = MagicMock() - config.datasets = [object()] - config.audit = MagicMock(only=True) - config.report_dir = str(tmp_path) - config.with_updates.return_value = config - - def _run_audit(cfg, base_report_dir): - result = MagicMock() - result.passed = False - result.test_id = "output_caching_test" - result.details = {"reason": "caching detected"} - return result - - monkeypatch.setattr(cli, "run_audit", _run_audit) - monkeypatch.setattr(cli, "run_benchmark", MagicMock()) - - with pytest.raises(CLIError): - cli._run(config, [], TestMode.PERF) diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index 591db62ce..3c8927c37 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -18,11 +18,18 @@ from __future__ import annotations import asyncio +import contextlib +import itertools import signal +import time from unittest.mock import MagicMock import pytest -from inference_endpoint.commands.benchmark.watchdog import SigintGovernor +from inference_endpoint.commands.benchmark.watchdog import ( + PerfPhaseTimeout, + SigintGovernor, +) +from inference_endpoint.load_generator.session import PhaseType def _fire(gov: SigintGovernor) -> None: @@ -37,27 +44,6 @@ def _distinct_fire(gov: SigintGovernor) -> None: @pytest.mark.unit class TestSigintGovernor: - def test_unbound_first_sigint_raises_keyboard_interrupt(self): - gov = SigintGovernor() - with pytest.raises(KeyboardInterrupt): - _fire(gov) - assert gov.interrupted - assert not gov.forced - - @pytest.mark.asyncio - async def test_live_run_first_sigint_stops_session_gracefully(self): - gov = SigintGovernor() - session = MagicMock() - gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) - gov.bind_session(session) - - _fire(gov) - await asyncio.sleep(0) # let the queued call_soon_threadsafe run - - assert gov.interrupted - assert not gov.forced - session.stop.assert_called_once() - def test_first_sigint_after_loop_returned_raises_immediately(self): """A ^C during sync finalization must not be swallowed. @@ -97,31 +83,133 @@ async def run_phase() -> None: assert gov.forced @pytest.mark.asyncio - async def test_duplicate_delivery_within_window_is_dropped(self): - """One keystroke forwarded by a runner (uv run) must count once.""" + @pytest.mark.parametrize("bound", [True, False], ids=["bound", "unbound"]) + @pytest.mark.parametrize( + "deliveries", + [ + seq + for n in (1, 2, 3) + for seq in itertools.product(("distinct", "dup"), repeat=n) + # A duplicate before any accepted delivery cannot occur: the dedup + # window opens on the first accepted ^C. + if seq[0] == "distinct" + ], + ids="-".join, + ) + async def test_delivery_sequences_exhaustive(self, bound, deliveries): + """Every bind-state x delivery-sequence (length <= 3), exhaustively. + + Contract: an accepted, distinct delivery is never silently dropped — + it schedules a graceful stop (first, bound), cancels the live run + task (second, bound), or raises KeyboardInterrupt (unbound). Only + duplicate deliveries inside the window are silent. Escalation to + force happens on exactly the second accepted delivery. + """ gov = SigintGovernor() session = MagicMock() - gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) - gov.bind_session(session) + run_task = asyncio.create_task(asyncio.sleep(30)) + await asyncio.sleep(0) # let the child task start + if bound: + gov.bind_task(run_task, asyncio.get_running_loop()) + gov.bind_session(session) - _fire(gov) - _fire(gov) # forwarded duplicate, inside the window - await asyncio.sleep(0) + accepted = 0 + try: + for kind in deliveries: + if kind == "dup": + # Inside the duplicate window of the previous delivery. + gov._last_accepted_monotonic = time.monotonic() + else: + gov._last_accepted_monotonic = float("-inf") + accepted += 1 + if kind == "distinct" and not bound: + with pytest.raises(KeyboardInterrupt): + gov(signal.SIGINT, None) + else: + gov(signal.SIGINT, None) # silent: bound or deduped + + assert gov.interrupted + assert gov.forced == (accepted >= 2) + if bound: + await asyncio.sleep(0) # run queued call_soon_threadsafe work + assert session.stop.call_count == 1 + if accepted >= 2: + # Force path cancelled the run task; let it settle. + with contextlib.suppress(asyncio.CancelledError): + await asyncio.wait_for(run_task, timeout=2.0) + assert run_task.cancelled() + else: + assert not run_task.done() + else: + session.stop.assert_not_called() + assert not run_task.done() + finally: + run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await run_task - assert gov.interrupted - assert not gov.forced - session.stop.assert_called_once() + +@pytest.mark.unit +class TestPerfPhaseTimeout: + """The max_duration_ms cap bounds only the performance phase and never + truncates a subsequent accuracy phase (regression: a combined + perf+accuracy run was guillotined mid-accuracy because the perf timer + was never cancelled). Exercised against the real running loop. + """ @pytest.mark.asyncio - async def test_second_distinct_sigint_cancels_live_run_task(self): - gov = SigintGovernor() - session = MagicMock() - gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) - gov.bind_session(session) + async def test_cap_fires_after_max_duration(self): + fired = asyncio.Event() + timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) - _fire(gov) - _distinct_fire(gov) - with pytest.raises(asyncio.CancelledError): - await asyncio.sleep(5) + timeout.on_phase_start(PhaseType.PERFORMANCE) - assert gov.forced + await asyncio.wait_for(fired.wait(), timeout=2.0) + + @pytest.mark.asyncio + async def test_accuracy_phase_start_disarms_pending_perf_cap(self): + fired = asyncio.Event() + timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + + timeout.on_phase_start(PhaseType.PERFORMANCE) + timeout.on_phase_start(PhaseType.ACCURACY) + + await asyncio.sleep(0.1) # 5x the cap: a leaked timer would have fired + assert not fired.is_set() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "max_duration_ms, phases", + [ + pytest.param(None, [PhaseType.PERFORMANCE], id="no-max-duration"), + pytest.param( + 20, + [PhaseType.WARMUP, PhaseType.ACCURACY], + id="non-performance-phases", + ), + ], + ) + async def test_never_armed(self, max_duration_ms, phases): + fired = asyncio.Event() + timeout = PerfPhaseTimeout( + asyncio.get_running_loop(), max_duration_ms, fired.set + ) + + for phase_type in phases: + timeout.on_phase_start(phase_type) + + await asyncio.sleep(0.1) + assert not fired.is_set() + + @pytest.mark.asyncio + async def test_cancel_is_idempotent_and_disarms(self): + fired = asyncio.Event() + timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + + timeout.cancel() # no handle yet — must not raise + timeout.on_phase_start(PhaseType.PERFORMANCE) + timeout.cancel() + timeout.cancel() + + await asyncio.sleep(0.1) + assert not fired.is_set() diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py index 4d2f35412..b7ac7b8fb 100644 --- a/tests/unit/config/test_schema.py +++ b/tests/unit/config/test_schema.py @@ -353,28 +353,6 @@ def test_online_max_throughput_rejected(self): settings={"load_pattern": {"type": "max_throughput"}}, ) - @pytest.mark.unit - def test_max_duration_zero_rejected(self): - with pytest.raises(ValueError, match="greater than 0"): - BenchmarkConfig( - type=TestType.OFFLINE, - model_params={"name": "M"}, - endpoint_config={"endpoints": ["http://x"]}, - datasets=[{"path": "D"}], - settings={"runtime": {"max_duration_ms": 0}}, - ) - - @pytest.mark.unit - def test_max_duration_below_zero_rejected(self): - with pytest.raises(ValueError, match="greater than 0"): - BenchmarkConfig( - type=TestType.OFFLINE, - model_params={"name": "M"}, - endpoint_config={"endpoints": ["http://x"]}, - datasets=[{"path": "D"}], - settings={"runtime": {"max_duration_ms": -1}}, - ) - @pytest.mark.unit def test_submission_bad_benchmark_mode(self): with pytest.raises(ValueError, match="benchmark_mode"): diff --git a/tests/unit/test_profiler.py b/tests/unit/test_profiler.py index 8fe9c1c01..cdbe648a4 100644 --- a/tests/unit/test_profiler.py +++ b/tests/unit/test_profiler.py @@ -34,55 +34,52 @@ ENV_VAR_ENABLE_LINE_PROFILER, ) +pytestmark = pytest.mark.unit + @pytest.fixture(autouse=True) -def cleanup_profiler(): - """Ensure profiler is cleaned up after each test.""" +def restore_profiler_singleton(): + """Restore the module-level singleton after any test that replaces it. + + The module's public API (``profile``, ``print_stats``, ...) is bound to + the singleton created at import time; tests that reset ``_instance`` and + re-init under a patched env must not leak that replacement (or a live C + profiler) into other tests. + """ + original = line_profiler.ProfilerState._instance yield + current = line_profiler.ProfilerState._instance + if current is not None and current is not original: + current.shutdown() + line_profiler.ProfilerState._instance = original + - # Clean up after test - if ( - line_profiler.ProfilerState._instance - and line_profiler.ProfilerState._instance.profiler - ): - try: - line_profiler.ProfilerState._instance.pause() - # Clear any accumulated stats - line_profiler.ProfilerState._instance._stats_printed = False - except Exception: - pass +@pytest.fixture +def enabled_profiler(): + """A fresh, enabled ProfilerState under ENABLE_LINE_PROFILER=1.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + line_profiler.ProfilerState._instance = None + yield line_profiler.ProfilerState() class TestProfilerState: """Test the ProfilerState singleton.""" def test_singleton_pattern(self): - """Test that ProfilerState follows singleton pattern.""" state1 = line_profiler.ProfilerState() state2 = line_profiler.ProfilerState() assert state1 is state2 def test_profiler_disabled_by_default(self): - """Test profiler is disabled when ENABLE_LINE_PROFILER is not set.""" with mock.patch.dict(os.environ, {}, clear=True): - # Force re-initialization line_profiler.ProfilerState._instance = None state = line_profiler.ProfilerState() assert not state.enabled assert state.profiler is None - def test_profiler_enabled_with_env_var(self): - """Test profiler is enabled when ENABLE_LINE_PROFILER=1.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - # Force re-initialization - line_profiler.ProfilerState._instance = None - try: - state = line_profiler.ProfilerState() - # Only check enabled flag, as line_profiler might not be installed - assert state.enabled - finally: - # Reset for other tests - line_profiler.ProfilerState._instance = None + def test_profiler_enabled_with_env_var(self, enabled_profiler): + # Only check the enabled flag: line_profiler might not be installed. + assert enabled_profiler.enabled class TestProfileDecorators: @@ -90,68 +87,38 @@ class TestProfileDecorators: @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_profile_decorator_sync_when_disabled(self): - """Test profile decorator returns original sync function when disabled.""" - @profile def test_func(x): return x * 2 - # When disabled, decorator should be no-op + # When disabled, decorator is a no-op and the function is unchanged. assert test_func(5) == 10 - # Function should be unchanged assert test_func.__name__ == "test_func" @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_profile_decorator_async_when_disabled(self): - """Test profile decorator returns original async function when disabled.""" - @profile async def test_async_func(x): await asyncio.sleep(0) return x * 2 - # When disabled, decorator should be no-op - result = asyncio.run(test_async_func(5)) - assert result == 10 - # Function should be unchanged + assert asyncio.run(test_async_func(5)) == 10 assert test_async_func.__name__ == "test_async_func" - def test_profile_decorator_sync_when_enabled(self): - """Test profile decorator wraps sync function when enabled.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - # Force re-initialization - line_profiler.ProfilerState._instance = None - - # Import after setting env var - from inference_endpoint.profiling.line_profiler import ProfilerState - - state = ProfilerState() - - @state.profile - def test_func(x): - return x * 2 - - result = test_func(5) - assert result == 10 - - def test_profile_decorator_async_when_enabled(self): - """Test profile decorator wraps async function when enabled.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - # Force re-initialization - line_profiler.ProfilerState._instance = None - - # Import after setting env var - from inference_endpoint.profiling.line_profiler import ProfilerState + def test_profile_decorator_sync_when_enabled(self, enabled_profiler): + @enabled_profiler.profile + def test_func(x): + return x * 2 - state = ProfilerState() + assert test_func(5) == 10 - @state.profile - async def test_async_func(x): - await asyncio.sleep(0) - return x * 2 + def test_profile_decorator_async_when_enabled(self, enabled_profiler): + @enabled_profiler.profile + async def test_async_func(x): + await asyncio.sleep(0) + return x * 2 - result = asyncio.run(test_async_func(5)) - assert result == 10 + assert asyncio.run(test_async_func(5)) == 10 class TestProfilerMethods: @@ -159,103 +126,65 @@ class TestProfilerMethods: @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_print_stats_when_disabled(self): - """Test print_stats does nothing when profiler is disabled.""" output = io.StringIO() print_stats(stream=output) assert output.getvalue() == "" @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_get_stats_when_disabled(self): - """Test get_stats returns empty string when profiler is disabled.""" - stats = get_stats() - assert stats == "" + assert get_stats() == "" def test_pause_resume_methods(self): - """Test pause/resume methods don't crash when called.""" - # Should not raise any exceptions + # Must not raise, enabled or not. resume() pause() - def test_print_stats_with_prefix(self): - """Test print_stats with a prefix.""" - output = io.StringIO() - print_stats(stream=output, prefix="Test Worker") - - # When disabled, should produce no output - if not is_enabled(): - assert output.getvalue() == "" - else: - # When enabled, should have the prefix in output - output_str = output.getvalue() - if output_str: # Only check if there's output - assert ( - "Test Worker - LINE PROFILER RESULTS" in output_str - or "Test Worker" in output_str - ) - - def test_print_stats_no_output_when_no_functions(self): - """Test print_stats produces no output when no functions have been profiled.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - line_profiler.ProfilerState._instance = None - state = line_profiler.ProfilerState() + def test_print_stats_no_output_when_no_functions(self, enabled_profiler): + if enabled_profiler.profiler is None: + pytest.skip("line_profiler not installed") + assert len(enabled_profiler.profiler.functions) == 0 - if state.profiler: - # Ensure no functions are profiled (fresh profiler) - assert len(state.profiler.functions) == 0 - - output = io.StringIO() - state.print_stats(stream=output, prefix="Test") + output = io.StringIO() + enabled_profiler.print_stats(stream=output, prefix="Test") - # Should produce no output when no functions are profiled - assert output.getvalue() == "" + assert output.getvalue() == "" class TestProfilerCleanup: - """Test profiler cleanup behavior.""" + """Shutdown must always tear the C profiler down, exactly once.""" - def test_shutdown_handles_multiple_calls(self): - """Test that shutdown can be called multiple times safely.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - line_profiler.ProfilerState._instance = None - state = line_profiler.ProfilerState() - - # Should not raise an exception when called multiple times - state.shutdown() - state.shutdown() - state.shutdown() + def test_shutdown_handles_multiple_calls(self, enabled_profiler): + enabled_profiler.shutdown() + enabled_profiler.shutdown() + enabled_profiler.shutdown() + assert enabled_profiler.profiler is None - def test_shutdown_after_print_stats_still_tears_down(self): + def test_shutdown_after_print_stats_still_tears_down(self, enabled_profiler): """print_stats() marks stats printed; shutdown() must still teardown.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - line_profiler.ProfilerState._instance = None - state = line_profiler.ProfilerState() - @state.profile - def traced(x): - return x + 1 + @enabled_profiler.profile + def traced(x): + return x + 1 - traced(1) - state.print_stats(stream=io.StringIO()) - assert state._stats_printed is True + traced(1) + enabled_profiler.print_stats(stream=io.StringIO()) + assert enabled_profiler._stats_printed is True - state.shutdown() - assert state.profiler is None + enabled_profiler.shutdown() + assert enabled_profiler.profiler is None - def test_shutdown_tears_down_when_output_destination_fails(self): + def test_shutdown_tears_down_when_output_destination_fails(self, enabled_profiler): """A failing stats dump must still leave the C profiler disabled.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - line_profiler.ProfilerState._instance = None - state = line_profiler.ProfilerState() - @state.profile - def traced(x): - return x + 1 - - traced(1) - with mock.patch.object( - state, - "_print_stats_to_destination", - side_effect=OSError("disk full"), - ): - state.shutdown() - assert state.profiler is None + @enabled_profiler.profile + def traced(x): + return x + 1 + + traced(1) + with mock.patch.object( + enabled_profiler, + "_print_stats_to_destination", + side_effect=OSError("disk full"), + ): + enabled_profiler.shutdown() + assert enabled_profiler.profiler is None From 0bbc883ecda65864925032c2df2591e4dce915c8 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 13:52:25 -0700 Subject: [PATCH 30/45] docs(interrupt): pin artifact-precedence contract; test the drain-window divergence nv-alicheng's review flagged that a SIGINT reaching only the main process can leave final_snapshot.json COMPLETE while result_summary.json is corrected to INTERRUPTED. This is deliberate - the snapshot is the aggregator's own observation, the summary is run-level truth - but the contract was undocumented and the divergence window untested, and three docs still claimed a ^C never leaves COMPLETE artifacts. - CLI_QUICK_REFERENCE, AGENTS.md, snapshot.py SessionState docstring, and the test_sigint module contract now state the precedence: result_summary + exit code are authoritative; final_snapshot can legitimately read state=complete when the abort lands in the post-ENDED metrics-drain window (and the aggregator ignores SIGINT - the parent's ENDED path is authoritative for ^C, with SIGTERM/marker-event as the INTERRUPTED entries). - New integration test pins the window deterministically: session ends, tokenization backlog drains, ^C to the main process only -> exit 130, summary state=interrupted complete=false, snapshot state=complete, no orphaned children. - execute.py's phase-start hook extracted to _make_phase_start_hook and its profiler-before-perf-cap ordering asserted directly (a production reorder now fails the test); the timing-based session test that re-implemented the ordering locally is deleted. --- AGENTS.md | 4 +- docs/CLI_QUICK_REFERENCE.md | 11 +- .../services/metrics_aggregator/snapshot.py | 17 ++- .../commands/benchmark/execute.py | 36 +++-- tests/integration/commands/test_sigint.py | 132 +++++++++++++++--- tests/unit/commands/test_benchmark.py | 45 ++++++ .../unit/load_generator/test_async_session.py | 37 ----- 7 files changed, 203 insertions(+), 79 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f364bae55..542d0a384 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,8 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. - **Series storage**: each `SeriesSampler` keeps three parallel views: O(1) cheap rollups (count/total/min/max/sum_sq, exact), an HDR Histogram (cheap live percentiles), and an in-memory `array.array` of raw values (for exact percentiles in the `COMPLETE` snapshot). Hot path is `registry.record(name, value)` — no allocation, no I/O. - **Counter API**: `registry.increment(name, delta=1)` for sample-event counters. `registry.set_counter(name, value)` only for the three derived-duration counters (`total_duration_ns` max-of-elapsed, `tracked_duration_ns` sum-of-blocks, `legacy_loadgen_window_duration_ns` first-issue→last-issued-completion span for LoadGen-parity QPS/TPS). -- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget / `settings.timeouts.metrics_drain_timeout_s`: None or omitted flag = unlimited, 0 = give up immediately) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (signal-handler-triggered final via SIGTERM/SIGINT; best-effort partial stats). Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. -- **Final delivery is dual-path with separated concerns**: `publish_final` atomically writes `final_snapshot.json` (`tmp + fsync(file) + rename + fsync(parent_dir)`) — this is the **primary** Report source — AND emits the terminal-state snapshot over pub/sub as a TUI shutdown signal. Each path is wrapped in its own try/except so one failure cannot suppress the other. Main process consumer reads `final_snapshot.json` (via `json.loads` to dict, no Struct decode); falls back to the subscriber's `latest` live snapshot only if the file is missing (e.g. SIGKILL / OOM before the signal handler ran). The dict form is the canonical consumer contract (see `snapshot_to_dict`). +- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget / `settings.timeouts.metrics_drain_timeout_s`: None or omitted flag = unlimited, 0 = give up immediately) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (best-effort partial stats; entered via the session's INTERRUPTED marker event — a graceful ^C stops the session, which still publishes ENDED — or via SIGTERM from the run watchdog). The aggregator **ignores SIGINT**: a terminal ^C reaches the whole foreground group, and the parent's ENDED path is authoritative. Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. +- **Final delivery is dual-path with separated concerns**: `publish_final` atomically writes `final_snapshot.json` (`tmp + fsync(file) + rename + fsync(parent_dir)`) — this is the **primary** Report build source — AND emits the terminal-state snapshot over pub/sub as a TUI shutdown signal. Each path is wrapped in its own try/except so one failure cannot suppress the other. Main process consumer reads `final_snapshot.json` (via `json.loads` to dict, no Struct decode); falls back to the subscriber's `latest` live snapshot only if the file is missing (e.g. SIGKILL / OOM before the signal handler ran). The dict form is the canonical consumer contract (see `snapshot_to_dict`). **Run-outcome precedence**: `final_snapshot.json` records what the aggregator itself observed and can legitimately read `state: complete` when an abort lands after the session's terminal ENDED (the metrics-drain window); `finalize_benchmark` rewrites the Report on any abort, so `performance/result_summary.json` + the process exit code are the run-level truth. - **Early stopping (on by default)**: series registered with `register_series(..., tail_latency=True)` (today ttft/tpot/latency) get MLPerf early-stopping percentile estimates on the COMPLETE (exact) snapshot — a compact `early_stopping_percentiles` map in `result_summary.json` whose keys mirror the `percentiles` grid (≥ p50) with estimate-or-`null` values; rich detail is INFO-logged. On by default (cold-path only; the exact path shares one in-place sort between the percentile grid and the estimates); `settings.early_stopping.enabled: false` / `--no-early-stopping` opts out. Confidence/tolerance are LoadGen constants. Pure math in `metrics/early_stopping.py`; post-hoc recomputation from any run's `events.jsonl` via `scripts/early_stopping_estimate_from_events.py`. See docs/early_stopping.md. - **Histogram bucket edges are dynamic per snapshot**: log-spaced over the observed `[min, max]`. Bucket count is fixed at construction; consumers MUST re-render from the snapshot's `(lo, hi, count)` triples each frame and MUST NOT track bucket-by-index across snapshots. diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 3eaeaa951..444a7f324 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -193,8 +193,8 @@ One handler owns SIGINT for the whole run: - **First ^C**: graceful abort. The session stops issuing, in-flight drains are released, buffered samples still reach the metrics aggregator, and the - artifacts land honest — `final_snapshot.json` `state: interrupted`, - `result_summary.json` `complete: false`, `events.jsonl` flushed. Exit 130. + artifacts land honest — `result_summary.json` `state: interrupted`, + `complete: false`, `events.jsonl` flushed. Exit 130. - **Any further ^C**: force quit — the teardown (metrics drain included) is abandoned, service children and HTTP workers are SIGKILLed, exit 130 with whatever artifacts were already written. One keystroke counts once: runners @@ -204,7 +204,12 @@ One handler owns SIGINT for the whole run: - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. -A ^C'd run never exits 0 and never writes `complete: true` artifacts. +A ^C'd run never exits 0 and its `result_summary.json` is never +`complete: true`. **Precedence**: `result_summary.json` + the exit code are +the run-level truth; `metrics/final_snapshot.json` records what the +aggregator itself observed and can legitimately read `state: complete` when +the ^C lands after the session already published its terminal ENDED (the +metrics-drain window) — the aggregator saw a run that ended normally. ## Environment Variables diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py index 59d2e1992..697be12fd 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py @@ -53,13 +53,16 @@ class SessionState(str, Enum): COMPLETE → terminal clean state. The ``publish_final()`` snapshot written from the ``ENDED`` path. Percentiles and histograms are exact (computed from raw values). - INTERRUPTED → terminal interrupted state. The ``publish_final()`` snapshot - written from a signal handler (SIGTERM / SIGINT) before - ``ENDED`` arrived. Stats are best-effort partial captures of - whatever the aggregator had at signal time — drain didn't - complete and raw values may be missing late samples. - Distinguishes "user killed the run" from "clean shutdown"; - Report renders this with a clear interrupted indicator. + INTERRUPTED → terminal interrupted state. Entered when the session's + INTERRUPTED marker event preceded ``ENDED`` (a graceful ^C + stops the session, which still publishes ENDED), or when + SIGTERM landed (run watchdog escalation) before ``ENDED``. + SIGINT itself is ignored — the parent's ENDED path is + authoritative for ^C. Stats are best-effort partial + captures — the drain didn't complete and raw values may be + missing late samples. Distinguishes "run aborted" from + "clean shutdown"; Report renders this with a clear + interrupted indicator. Transitions are forward-only: INITIALIZE → LIVE → DRAINING → {COMPLETE | INTERRUPTED} diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index a89b55b28..e5b1d89e5 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -36,7 +36,7 @@ import tempfile import time import uuid -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from dataclasses import replace as dataclass_replace from datetime import datetime @@ -771,6 +771,28 @@ def _on_sample_complete(result: QueryResult) -> None: return _on_sample_complete +def _make_phase_start_hook( + profiler: ProfileController, perf_timeout: PerfPhaseTimeout +) -> Callable[[PhaseConfig], Awaitable[None]]: + """The session's per-phase-start hook: arm the profiler, then the perf cap. + + Ordering is load-bearing: /start_profile is awaited BEFORE the perf cap is + armed. ``_run_phase`` clears the phase-stop flag at entry, so a one-shot + cap that fired while profile arming was still awaiting would be silently + erased and the phase would run uncapped. (On non-PERFORMANCE phases this + only cancels the perf timer, so accuracy is never truncated.) + """ + + async def _on_phase_start(phase: PhaseConfig) -> None: + if phase.phase_type == PhaseType.PERFORMANCE: + # Fire /start_profile sequentially before any perf request is + # issued, so the server is armed when traffic begins. + await profiler.start() + perf_timeout.on_phase_start(phase.phase_type) + + return _on_phase_start + + async def _run_benchmark_async( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop, @@ -891,17 +913,7 @@ def _on_global_timeout() -> None: loop, max_duration_ms, _on_global_timeout ) - async def _on_phase_start(phase: PhaseConfig) -> None: - if phase.phase_type == PhaseType.PERFORMANCE: - # Fire /start_profile sequentially before any perf request - # is issued, so the server is armed when traffic begins. - await profiler.start() - # Arm the perf cap LAST — _run_phase clears the phase-stop - # flag at entry, so a one-shot cap that fired while profile - # arming was still awaiting would be silently erased and the - # phase would run uncapped. (On non-PERFORMANCE phases this - # cancels the perf timer, so accuracy is never truncated.) - perf_timeout.on_phase_start(phase.phase_type) + _on_phase_start = _make_phase_start_hook(profiler, perf_timeout) try: # A pre-session fire already stopped the session inside diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 76c6a0ff4..69f199f7a 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -21,10 +21,13 @@ service children alike). The contract, at every delivery point: - exit code 130 (user abort, distinct from failure exit codes 1-4); -- artifacts are honest: ``final_snapshot.json`` ``state=interrupted`` (the - session's INTERRUPTED marker drives the aggregator's ENDED finalize) and - ``result_summary.json`` ``complete: false`` — or no artifacts at all, - never a COMPLETE-looking report from an aborted run; +- artifacts are honest: ``result_summary.json`` ``state=interrupted``, + ``complete: false`` — or no artifacts at all, never a COMPLETE-looking + run outcome. ``final_snapshot.json`` usually reads ``state=interrupted`` + too (the session's INTERRUPTED marker drives the aggregator's ENDED + finalize), except in the post-ENDED drain window, where the aggregator + legitimately records the normally-ended run it observed — + ``result_summary.json`` + the exit code are the run-level truth; - ``events.jsonl`` survives — the event logger ignores the group SIGINT and flushes on the session's terminal ENDED; - no service child outlives the run; @@ -40,6 +43,7 @@ import time from collections.abc import Iterator from pathlib import Path +from typing import IO import pytest @@ -85,20 +89,32 @@ def _cli() -> str: @contextlib.contextmanager -def _benchmark_proc(argv: list[str]) -> Iterator[subprocess.Popen]: - """A benchmark subprocess in its own group, SIGKILLed on exit if alive.""" - proc = subprocess.Popen( - argv, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, # own process group, like a foreground job - ) - try: - yield proc - finally: - if proc.poll() is None: - os.killpg(proc.pid, signal.SIGKILL) - proc.wait() +def _benchmark_proc( + argv: list[str], log_file: Path | None = None +) -> Iterator[subprocess.Popen]: + """A benchmark subprocess in its own group, SIGKILLed on exit if alive. + + ``log_file`` captures stdout+stderr (logging goes to stdout) for tests + that key on run-lifecycle log lines. + """ + with contextlib.ExitStack() as stack: + out: IO[bytes] | int = ( + stack.enter_context(log_file.open("wb")) + if log_file is not None + else subprocess.DEVNULL + ) + proc = subprocess.Popen( + argv, + stdout=out, + stderr=subprocess.STDOUT if log_file is not None else subprocess.DEVNULL, + start_new_session=True, # own process group, like a foreground job + ) + try: + yield proc + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() def _wait_services_ready( @@ -294,3 +310,83 @@ def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp assert rc == 130, f"user abort must exit 130, got {rc}" # The graceful path writes the report; a force quit would have skipped it. _assert_interrupted_artifacts(report_dir) + + +@pytest.mark.integration +def test_sigint_in_drain_window_keeps_summary_authoritative( + mock_http_echo_server, tmp_path +): + """^C after the session's terminal ENDED: result_summary is the truth. + + The session ends normally and the aggregator drains a deferred + tokenization backlog when the ^C reaches the main process. The session + never publishes an INTERRUPTED marker (it already ENDED) and the + aggregator is not signaled, so final_snapshot.json legitimately reads + state=complete — that is what the aggregator observed. The run-level + artifacts must still be honest: exit 130 and result_summary.json + state=interrupted, complete=false. Pins the artifact-precedence contract + (docs/CLI_QUICK_REFERENCE.md "Ctrl-C (SIGINT)"). + """ + dataset_path = tmp_path / "big_prompts.jsonl" + prompt = "lorem ipsum " * 21_000 # ~250 KB per sample + with dataset_path.open("w") as f: + for i in range(30): + f.write(json.dumps({"prompt": f"{i} {prompt}"}) + "\n") + + report_dir = tmp_path / "report" + config_path = tmp_path / "bench.yaml" + config_path.write_text( + f""" +type: offline +endpoint_config: + endpoints: ["{mock_http_echo_server.url}"] +model_params: + name: "{_CHAR_TOKENIZER_DIR}" + streaming: "off" +datasets: + - path: "{dataset_path}" + type: performance +report_dir: {report_dir} +settings: + load_pattern: + type: max_throughput + client: + num_workers: 1 + warmup_connections: 0 + max_connections: 10 + metrics_tokenizer_workers: 0 # defer all tokenization to the drain + warmup: + enabled: false +""" + ) + + log_file = tmp_path / "run.log" + with _benchmark_proc( + [_cli(), "benchmark", "from-config", "-c", str(config_path)], + log_file=log_file, + ) as proc: + # The pipeline logs this line once the session has ENDED and the + # aggregator drain wait begins — the divergence window. + deadline = time.monotonic() + 120.0 + while "Waiting for services to finish processing" not in log_file.read_text( + errors="replace" + ): + assert proc.poll() is None, "benchmark exited before the drain" + assert time.monotonic() < deadline, "drain window never reached" + time.sleep(0.05) + + os.kill(proc.pid, signal.SIGINT) # main process only: aggregator unsignaled + rc = proc.wait(timeout=120.0) + + assert rc == 130, f"drain-window ^C must exit 130, got {rc}" + + summary = json.loads( + (report_dir / "performance" / "result_summary.json").read_text() + ) + assert summary["state"] == "interrupted" + assert summary["complete"] is False + + # The aggregator observed a normally-ended run: its own artifact says so. + snapshot = json.loads((report_dir / "metrics" / "final_snapshot.json").read_text()) + assert snapshot["state"] == "complete" + _assert_no_leftover_children(report_dir, "drain-window run") diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index a7f590298..cbdf39d9e 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -2815,6 +2815,51 @@ def test_logs_external_sample_count_for_skip_endpoint_phase_scorer( ) +class TestPhaseStartHook: + """_make_phase_start_hook ordering: profiler armed before the perf cap. + + The order is load-bearing — _run_phase clears the phase-stop flag at + entry, so a perf cap armed before the awaited profile arming could fire + during the await and be silently erased (phase runs uncapped). + """ + + @pytest.mark.unit + @pytest.mark.asyncio + async def test_profiler_start_completes_before_perf_cap_arms(self): + order: list[str] = [] + profiler = MagicMock() + + async def _start() -> None: + await asyncio.sleep(0) # a real suspend, like the to_thread POSTs + order.append("profiler.start") + + profiler.start = _start + perf_timeout = MagicMock() + perf_timeout.on_phase_start.side_effect = lambda pt: order.append("cap.armed") + + hook = execute_mod._make_phase_start_hook(profiler, perf_timeout) + await hook(MagicMock(phase_type=PhaseType.PERFORMANCE)) + + assert order == ["profiler.start", "cap.armed"] + perf_timeout.on_phase_start.assert_called_once_with(PhaseType.PERFORMANCE) + + @pytest.mark.unit + @pytest.mark.asyncio + @pytest.mark.parametrize("phase_type", [PhaseType.WARMUP, PhaseType.ACCURACY]) + async def test_non_performance_phase_skips_profiler_still_arms_timer( + self, phase_type + ): + profiler = MagicMock() + perf_timeout = MagicMock() + + hook = execute_mod._make_phase_start_hook(profiler, perf_timeout) + await hook(MagicMock(phase_type=phase_type)) + + profiler.start.assert_not_called() + # on_phase_start cancels the perf timer for non-PERFORMANCE phases. + perf_timeout.on_phase_start.assert_called_once_with(phase_type) + + class TestProfilingHelpers: @pytest.mark.unit @pytest.mark.parametrize( diff --git a/tests/unit/load_generator/test_async_session.py b/tests/unit/load_generator/test_async_session.py index a0446c7d5..4ec4e04ed 100644 --- a/tests/unit/load_generator/test_async_session.py +++ b/tests/unit/load_generator/test_async_session.py @@ -21,7 +21,6 @@ import random import pytest -from inference_endpoint.commands.benchmark.watchdog import PerfPhaseTimeout from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import LoadPattern, LoadPatternType from inference_endpoint.core.record import ( @@ -643,42 +642,6 @@ async def hook(phase: PhaseConfig) -> None: assert hook_done assert result.perf_results[0].issued_count == 3 - @pytest.mark.asyncio - async def test_perf_cap_armed_after_slow_hook_still_bounds_phase(self): - """A perf cap shorter than a slow phase-start hook must still fire. - - Mirrors execute.py's ``_on_phase_start`` ordering: the one-shot - ``PerfPhaseTimeout`` is armed AFTER the hook's await (profile arming). - Armed before it, a cap shorter than the hook delay fires while the - hook is still awaiting; ``_run_phase`` then clears the phase-stop - flag at entry, the fire is erased, and the phase runs uncapped. - """ - loop = asyncio.get_running_loop() - issuer = FakeIssuer() - issuer._loop = loop - publisher = FakePublisher() - session = BenchmarkSession(issuer, publisher, loop) - - perf_timeout = PerfPhaseTimeout(loop, 30, session.stop_current_phase) - - async def hook(phase: PhaseConfig) -> None: - await asyncio.sleep(0.05) # slow profile arming, longer than the cap - perf_timeout.on_phase_start(phase.phase_type) - - phases = [ - PhaseConfig( - "perf", - _make_settings(n_samples=100_000, max_duration_ms=10_000), - FakeDataset(100), - PhaseType.PERFORMANCE, - ), - ] - result = await asyncio.wait_for( - session.run(phases, on_phase_start=hook), timeout=10.0 - ) - - assert result.perf_results[0].issued_count < 100_000 - @pytest.mark.asyncio async def test_stop_current_phase_unblocks_unbounded_drain(self): """The per-phase cap must break an in-progress unbounded drain wait. From ed776dfb92cbfae264dbc0352c03fcef9a888be7 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 13:59:29 -0700 Subject: [PATCH 31/45] test(interrupt): pin the finalization-window ^C contract A ^C landing during post-measurement accuracy scoring (run task done, governor raises KeyboardInterrupt mid-score_accuracy) exits 130, and finalize_benchmark's finally still writes the genuinely completed perf report as complete:true. That is the honest outcome - the measurement finished; only post-measurement scoring was aborted. Documented as the one exception to 'a ^C'd run's summary is never complete' in CLI_QUICK_REFERENCE, the finalize comment, and the sigint test module contract; pinned by a unit regression (score_accuracy raising KeyboardInterrupt -> summary complete:true, interrupt propagates). Shared complete-report builder extracted for the two finalize guards. --- docs/CLI_QUICK_REFERENCE.md | 16 +++-- .../commands/benchmark/execute.py | 5 +- tests/integration/commands/test_sigint.py | 18 +++--- tests/unit/commands/test_benchmark.py | 62 +++++++++++++++---- 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 444a7f324..55f6ee874 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -204,12 +204,16 @@ One handler owns SIGINT for the whole run: - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. -A ^C'd run never exits 0 and its `result_summary.json` is never -`complete: true`. **Precedence**: `result_summary.json` + the exit code are -the run-level truth; `metrics/final_snapshot.json` records what the -aggregator itself observed and can legitimately read `state: complete` when -the ^C lands after the session already published its terminal ENDED (the -metrics-drain window) — the aggregator saw a run that ended normally. +A ^C'd run never exits 0. `result_summary.json` is never `complete: true` +for a run whose measurement was cut short — with one honest exception: a ^C +landing during post-measurement finalization (accuracy scoring) of an +already-completed run still exits 130, but keeps the completed perf +artifacts as written; the measurement really finished. **Precedence**: +`result_summary.json` + the exit code are the run-level truth; +`metrics/final_snapshot.json` records what the aggregator itself observed +and can legitimately read `state: complete` when the ^C lands after the +session already published its terminal ENDED (the metrics-drain window) — +the aggregator saw a run that ended normally. ## Environment Variables diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index e5b1d89e5..9de7daac3 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -1255,7 +1255,10 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # is written in the `finally` below so a scoring failure (e.g. lcb-service # unreachable, missing eval subproject, bad extras) still leaves the perf # run's result_summary.json / report.txt on disk instead of discarding them — - # then the exception propagates as before. + # then the exception propagates as before. The same holds for a ^C landing + # here (the governor raises KeyboardInterrupt mid-scoring once the run task + # is done): the run's measurement genuinely completed, so the completed perf + # artifacts are written as-is and the interrupt propagates for exit 130. accuracy_scores: list[dict[str, Any]] = [] try: if aborted: diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 69f199f7a..6977412c2 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -21,13 +21,17 @@ service children alike). The contract, at every delivery point: - exit code 130 (user abort, distinct from failure exit codes 1-4); -- artifacts are honest: ``result_summary.json`` ``state=interrupted``, - ``complete: false`` — or no artifacts at all, never a COMPLETE-looking - run outcome. ``final_snapshot.json`` usually reads ``state=interrupted`` - too (the session's INTERRUPTED marker drives the aggregator's ENDED - finalize), except in the post-ENDED drain window, where the aggregator - legitimately records the normally-ended run it observed — - ``result_summary.json`` + the exit code are the run-level truth; +- artifacts are honest: a run whose measurement was cut short lands + ``result_summary.json`` ``state=interrupted``, ``complete: false`` — or no + artifacts at all, never a COMPLETE-looking outcome for an aborted + measurement. (A ^C during post-measurement finalization of an + already-completed run keeps the completed perf artifacts; see + TestFinalizeBenchmark.) ``final_snapshot.json`` usually reads + ``state=interrupted`` too (the session's INTERRUPTED marker drives the + aggregator's ENDED finalize), except in the post-ENDED drain window, + where the aggregator legitimately records the normally-ended run it + observed — ``result_summary.json`` + the exit code are the run-level + truth; - ``events.jsonl`` survives — the event logger ignores the group SIGINT and flushes on the session's terminal ENDED; - no service child outlives the run; diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index cbdf39d9e..9712e54df 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -2262,7 +2262,55 @@ def test_aborted_run_never_writes_complete_artifacts(self, tmp_path, abort_field state:interrupted; an aborted run must never ship complete artifacts.""" config = OfflineConfig(**_OFFLINE_KWARGS) ctx = _make_benchmark_context(config=config, report_dir=tmp_path) - report = Report.from_snapshot( + report = self._make_complete_report() + assert report.complete is True, "precondition: aggregator said COMPLETE" + bench = _make_benchmark_result(tmp_path) + bench.report = report + setattr(bench, abort_field, True) + + finalize_benchmark(ctx, bench) + + summary = json.loads( + (tmp_path / "performance" / "result_summary.json").read_text() + ) + assert summary["complete"] is False + assert summary["state"] == "interrupted" + + @pytest.mark.unit + def test_sigint_during_scoring_keeps_completed_perf_artifacts( + self, tmp_path, monkeypatch + ): + """^C in the finalization window: completed perf artifacts survive. + + The run genuinely completed (no abort flag) and the governor's + KeyboardInterrupt lands mid-scoring. The interrupt must propagate + (main.py exits 130) but the finally still writes the perf report as + complete:true — the measurement finished; only post-measurement + scoring was aborted. This is the documented exception to "a ^C'd + run's summary is never complete" (CLI_QUICK_REFERENCE "Ctrl-C"). + """ + config = OfflineConfig(**_OFFLINE_KWARGS) + ctx = _make_benchmark_context(config=config, report_dir=tmp_path) + bench = _make_benchmark_result(tmp_path) + bench.report = self._make_complete_report() + monkeypatch.setattr( + execute_mod, + "score_accuracy", + MagicMock(side_effect=KeyboardInterrupt), + ) + + with pytest.raises(KeyboardInterrupt): + finalize_benchmark(ctx, bench) + + summary = json.loads( + (tmp_path / "performance" / "result_summary.json").read_text() + ) + assert summary["complete"] is True + assert summary["state"] == "complete" + + @staticmethod + def _make_complete_report() -> Report: + return Report.from_snapshot( { "counter": 1, "timestamp_ns": 12345, @@ -2284,18 +2332,6 @@ def test_aborted_run_never_writes_complete_artifacts(self, tmp_path, abort_field ], } ) - assert report.complete is True, "precondition: aggregator said COMPLETE" - bench = _make_benchmark_result(tmp_path) - bench.report = report - setattr(bench, abort_field, True) - - finalize_benchmark(ctx, bench) - - summary = json.loads( - (tmp_path / "performance" / "result_summary.json").read_text() - ) - assert summary["complete"] is False - assert summary["state"] == "interrupted" class TestScorerMethodSync: From bffa94dce92e9f1f6110ca72de883233ca3eb95c Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 14:32:33 -0700 Subject: [PATCH 32/45] chore(tests): resolve CodeQL findings Single import style for the cli module (string-target monkeypatch, _run imported directly); explicit awaited cancellation via wait_for in the governor matrix teardown. --- tests/unit/commands/test_benchmark.py | 43 +++++++++++++++++++-------- tests/unit/commands/test_watchdog.py | 2 +- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 9712e54df..6140a6489 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -27,11 +27,11 @@ from unittest.mock import AsyncMock, MagicMock, patch from urllib import error as urllib_error -import inference_endpoint.commands.benchmark.cli as cli_mod import inference_endpoint.commands.benchmark.execute as execute_mod import pandas as pd import pytest from inference_endpoint.commands.benchmark.cli import ( + _run, benchmark_app, from_config, offline, @@ -3290,12 +3290,16 @@ def test_keyboard_interrupt_skips_audit(self, monkeypatch, tmp_path): def _interrupt(cfg, mode): raise KeyboardInterrupt - monkeypatch.setattr(cli_mod, "run_benchmark", _interrupt) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_benchmark", _interrupt + ) audit_spy = MagicMock() - monkeypatch.setattr(cli_mod, "run_audit", audit_spy) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_audit", audit_spy + ) with pytest.raises(KeyboardInterrupt): - cli_mod._run(config, [], TestMode.PERF) + _run(config, [], TestMode.PERF) audit_spy.assert_not_called() @pytest.mark.unit @@ -3317,10 +3321,14 @@ def _run_benchmark(cfg, mode): call_order.append(("benchmark", cfg, mode)) return tmp_path - monkeypatch.setattr(cli_mod, "run_audit", _run_audit) - monkeypatch.setattr(cli_mod, "run_benchmark", _run_benchmark) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_audit", _run_audit + ) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_benchmark", _run_benchmark + ) - cli_mod._run(config, [], TestMode.PERF) + _run(config, [], TestMode.PERF) assert [c[0] for c in call_order] == ["benchmark", "audit"] _, benchmark_cfg, _ = call_order[0] @@ -3336,11 +3344,16 @@ def test_audit_fail_raises_cli_error(self, monkeypatch, tmp_path, only): """A failing (not crashed) audit maps to CLIError (exit 1) — both after a passing main run and standalone via audit.only.""" config = _audit_cli_config(tmp_path, only=only) - monkeypatch.setattr(cli_mod, "run_audit", _failing_audit) - monkeypatch.setattr(cli_mod, "run_benchmark", MagicMock(return_value=tmp_path)) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_audit", _failing_audit + ) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_benchmark", + MagicMock(return_value=tmp_path), + ) with pytest.raises(CLIError): - cli_mod._run(config, [], TestMode.PERF) + _run(config, [], TestMode.PERF) @pytest.mark.unit def test_audit_only_skips_main_run(self, monkeypatch, tmp_path): @@ -3354,11 +3367,15 @@ def _run_audit(cfg, base_report_dir): result.passed = True return result - monkeypatch.setattr(cli_mod, "run_audit", _run_audit) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_audit", _run_audit + ) benchmark_spy = MagicMock() - monkeypatch.setattr(cli_mod, "run_benchmark", benchmark_spy) + monkeypatch.setattr( + "inference_endpoint.commands.benchmark.cli.run_benchmark", benchmark_spy + ) - cli_mod._run(config, [], TestMode.PERF) + _run(config, [], TestMode.PERF) benchmark_spy.assert_not_called() assert audit_calls == [tmp_path / "audit"] diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index 3c8927c37..cf6660d32 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -146,7 +146,7 @@ async def test_delivery_sequences_exhaustive(self, bound, deliveries): finally: run_task.cancel() with contextlib.suppress(asyncio.CancelledError): - await run_task + await asyncio.wait_for(run_task, timeout=2.0) @pytest.mark.unit From 86cf69403659c192326c15bb9d650afd41fc5212 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 15:46:50 -0700 Subject: [PATCH 33/45] =?UTF-8?q?refactor(interrupt):=20one=20^C=20behavio?= =?UTF-8?q?r=20=E2=80=94=20graceful=20stop=20with=20bounded=20teardown=20g?= =?UTF-8?q?race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplification pass modeled on aiperf's interrupt design: a ^C has one meaning. It stops the session gracefully and arms a 30s teardown grace; if the metrics drain has not finished when the grace expires, the service children are SIGTERMed (the aggregator's handler writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort - escalation is timeout-driven, not keystroke-driven. Repeat ^C is a logged no-op, which also makes group-SIGINT-forwarding runners (uv run) need no special handling. Deleted with the second-^C force-quit semantics: the duplicate-delivery window and forced state in SigintGovernor, the force_quit predicate and kill_now fast path in MetricsPipeline, the forced-quit exception branch in _run_benchmark_async, and http_client.kill_workers / worker_manager.kill_now (endpoint_client files are back to main verbatim; main's worker teardown is already bounded). Also deferred to a follow-up (files back to main verbatim): the line-profiler explicit-shutdown refactor and the threaded profile POSTs - with no force-cancel to unblock, the sync POSTs are fine again, and the session's on_phase_start hook returns to a plain sync callback (no await window, so the perf-cap arming order is no longer load-bearing). A ^C during post-measurement accuracy scoring now rewrites the report to interrupted/complete:false before it is persisted (an interrupted run is an invalid run; artifacts only expose the partial metrics) and the interrupt propagates for exit 130. Tests follow the model: governor suite covers graceful+grace arming, repeat no-op, grace expiry, disarm-on-drain, and the no-live-run raise; the wedged-drain integration test now needs only a single ^C (grace shrunk to 3s via the class constant); the uv one-keystroke and drain-window divergence tests are unchanged in spirit. --- AGENTS.md | 2 +- docs/CLI_QUICK_REFERENCE.md | 39 ++-- .../commands/benchmark/execute.py | 163 ++++++-------- .../commands/benchmark/pipeline.py | 42 ++-- .../commands/benchmark/profiling.py | 17 +- .../commands/benchmark/watchdog.py | 98 +++++---- .../endpoint_client/http_client.py | 10 - .../endpoint_client/worker.py | 5 - .../endpoint_client/worker_manager.py | 10 - .../load_generator/session.py | 6 +- src/inference_endpoint/main.py | 5 - .../profiling/line_profiler.py | 60 ++++-- .../profiling/pytest_profiling_plugin.py | 15 ++ tests/integration/commands/test_sigint.py | 57 +++-- tests/unit/commands/test_benchmark.py | 96 ++------- tests/unit/commands/test_watchdog.py | 143 +++++------- .../unit/load_generator/test_async_session.py | 7 +- tests/unit/test_profiler.py | 203 ++++++++++-------- 18 files changed, 451 insertions(+), 527 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 542d0a384..19ecae149 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ The aggregator is a separate process (`python -m inference_endpoint.async_utils. - **Series storage**: each `SeriesSampler` keeps three parallel views: O(1) cheap rollups (count/total/min/max/sum_sq, exact), an HDR Histogram (cheap live percentiles), and an in-memory `array.array` of raw values (for exact percentiles in the `COMPLETE` snapshot). Hot path is `registry.record(name, value)` — no allocation, no I/O. - **Counter API**: `registry.increment(name, delta=1)` for sample-event counters. `registry.set_counter(name, value)` only for the three derived-duration counters (`total_duration_ns` max-of-elapsed, `tracked_duration_ns` sum-of-blocks, `legacy_loadgen_window_duration_ns` first-issue→last-issued-completion span for LoadGen-parity QPS/TPS). -- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget / `settings.timeouts.metrics_drain_timeout_s`: None or omitted flag = unlimited, 0 = give up immediately) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (best-effort partial stats; entered via the session's INTERRUPTED marker event — a graceful ^C stops the session, which still publishes ENDED — or via SIGTERM from the run watchdog). The aggregator **ignores SIGINT**: a terminal ^C reaches the whole foreground group, and the parent's ENDED path is authoritative. Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. +- **Lifecycle**: `INITIALIZE` (constructed, awaiting first `STARTED`) → `LIVE` (run in progress, ticking every `--publish-interval` seconds) → `DRAINING` (set on `ENDED`; tick continues; bounded by the `--drain-timeout` budget / `settings.timeouts.metrics_drain_timeout_s`: None or omitted flag = unlimited, 0 = give up immediately) → terminal: `COMPLETE` (clean end via `publish_final`, exact stats) **or** `INTERRUPTED` (best-effort partial stats; entered via the session's INTERRUPTED marker event — a graceful ^C stops the session, which still publishes ENDED — or via SIGTERM from the run watchdog / the ^C teardown grace). The aggregator **ignores SIGINT**: a terminal ^C reaches the whole foreground group, and the parent's ENDED path is authoritative. Drain timeout detected by consumers as `state == COMPLETE and n_pending_tasks > 0` — `run_benchmark` fails the run on it (artifacts written with `complete: false`, then non-zero exit); interrupted runs are detected as `state == INTERRUPTED` directly. - **Final delivery is dual-path with separated concerns**: `publish_final` atomically writes `final_snapshot.json` (`tmp + fsync(file) + rename + fsync(parent_dir)`) — this is the **primary** Report build source — AND emits the terminal-state snapshot over pub/sub as a TUI shutdown signal. Each path is wrapped in its own try/except so one failure cannot suppress the other. Main process consumer reads `final_snapshot.json` (via `json.loads` to dict, no Struct decode); falls back to the subscriber's `latest` live snapshot only if the file is missing (e.g. SIGKILL / OOM before the signal handler ran). The dict form is the canonical consumer contract (see `snapshot_to_dict`). **Run-outcome precedence**: `final_snapshot.json` records what the aggregator itself observed and can legitimately read `state: complete` when an abort lands after the session's terminal ENDED (the metrics-drain window); `finalize_benchmark` rewrites the Report on any abort, so `performance/result_summary.json` + the process exit code are the run-level truth. - **Early stopping (on by default)**: series registered with `register_series(..., tail_latency=True)` (today ttft/tpot/latency) get MLPerf early-stopping percentile estimates on the COMPLETE (exact) snapshot — a compact `early_stopping_percentiles` map in `result_summary.json` whose keys mirror the `percentiles` grid (≥ p50) with estimate-or-`null` values; rich detail is INFO-logged. On by default (cold-path only; the exact path shares one in-place sort between the percentile grid and the estimates); `settings.early_stopping.enabled: false` / `--no-early-stopping` opts out. Confidence/tolerance are LoadGen constants. Pure math in `metrics/early_stopping.py`; post-hoc recomputation from any run's `events.jsonl` via `scripts/early_stopping_estimate_from_events.py`. See docs/early_stopping.md. - **Histogram bucket edges are dynamic per snapshot**: log-spaced over the observed `[min, max]`. Bucket count is fixed at construction; consumers MUST re-render from the snapshot's `(lo, hi, count)` triples each frame and MUST NOT track bucket-by-index across snapshots. diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 55f6ee874..52a575705 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -189,32 +189,37 @@ How the knobs compose: ### Ctrl-C (SIGINT) -One handler owns SIGINT for the whole run: - -- **First ^C**: graceful abort. The session stops issuing, in-flight drains are - released, buffered samples still reach the metrics aggregator, and the - artifacts land honest — `result_summary.json` `state: interrupted`, - `complete: false`, `events.jsonl` flushed. Exit 130. -- **Any further ^C**: force quit — the teardown (metrics drain included) is - abandoned, service children and HTTP workers are SIGKILLed, exit 130 with - whatever artifacts were already written. One keystroke counts once: runners - that forward the terminal's group SIGINT to their child (`uv run` does) - deliver a single ^C twice microseconds apart — the duplicate delivery is - suppressed, so only a deliberate second press forces. +One handler owns SIGINT for the whole run, with one behavior: + +- **^C during a run**: graceful abort. The session stops issuing, in-flight + drains are released, buffered samples still reach the metrics aggregator, + and the artifacts land honest — `result_summary.json` `state: interrupted`, + `complete: false`, `events.jsonl` flushed. Exit 130. Teardown is bounded: + if the metrics drain has not finished within 30 s of the ^C, the service + children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED + snapshot) then SIGKILLed — a wedged drain can never hang the abort. +- **Repeat ^C**: logged no-op — the stop is already in flight and the grace + bounds the teardown. Runners that forward the terminal's group SIGINT to + their child (`uv run` does) deliver a single ^C twice; the duplicate is + equally harmless. - **^C during setup** (dataset/tokenizer load, before services): immediate abort, exit 130, no artifacts. -A ^C'd run never exits 0. `result_summary.json` is never `complete: true` -for a run whose measurement was cut short — with one honest exception: a ^C -landing during post-measurement finalization (accuracy scoring) of an -already-completed run still exits 130, but keeps the completed perf -artifacts as written; the measurement really finished. **Precedence**: +A ^C'd run never exits 0 and its `result_summary.json` is never +`complete: true` — a ^C landing during post-measurement finalization +(accuracy scoring) of an already-completed run also rewrites the report to +`state: interrupted` before it is persisted. **Precedence**: `result_summary.json` + the exit code are the run-level truth; `metrics/final_snapshot.json` records what the aggregator itself observed and can legitimately read `state: complete` when the ^C lands after the session already published its terminal ENDED (the metrics-drain window) — the aggregator saw a run that ended normally. +An interrupted (or timed-out) run is an **invalid run** — not a usable +result. Its artifacts exist only to expose whatever partial metrics were +available at abort time (diagnostics, triage), never to stand as a +benchmark outcome; the non-zero exit code is the machine-readable verdict. + ## Environment Variables **In YAML files** — use `${VAR}` or `${VAR:-default}` syntax: diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 9de7daac3..b1d0e6dd2 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -36,7 +36,7 @@ import tempfile import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass, field from dataclasses import replace as dataclass_replace from datetime import datetime @@ -771,28 +771,6 @@ def _on_sample_complete(result: QueryResult) -> None: return _on_sample_complete -def _make_phase_start_hook( - profiler: ProfileController, perf_timeout: PerfPhaseTimeout -) -> Callable[[PhaseConfig], Awaitable[None]]: - """The session's per-phase-start hook: arm the profiler, then the perf cap. - - Ordering is load-bearing: /start_profile is awaited BEFORE the perf cap is - armed. ``_run_phase`` clears the phase-stop flag at entry, so a one-shot - cap that fired while profile arming was still awaiting would be silently - erased and the phase would run uncapped. (On non-PERFORMANCE phases this - only cancels the perf timer, so accuracy is never truncated.) - """ - - async def _on_phase_start(phase: PhaseConfig) -> None: - if phase.phase_type == PhaseType.PERFORMANCE: - # Fire /start_profile sequentially before any perf request is - # issued, so the server is armed when traffic begins. - await profiler.start() - perf_timeout.on_phase_start(phase.phase_type) - - return _on_phase_start - - async def _run_benchmark_async( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop, @@ -831,9 +809,6 @@ async def _run_benchmark_async( event_log_dir=event_log_dir, metrics_output_dir=metrics_output_dir, loop=loop, - # Second ^C: the pipeline teardown SIGKILLs instead of the graceful - # SIGTERM-and-wait, regardless of where the cancellation unwound from. - force_quit=lambda: sigint is not None and sigint.forced, ) report: Report | None = None profiler: ProfileController @@ -886,7 +861,12 @@ async def _run_benchmark_async( ) watchdog.bind_session(session) if sigint is not None: - sigint.bind_session(session) + # On ^C the governor stops the session and arms a teardown + # grace timer; expiry SIGTERMs the aggregator (its handler + # writes a best-effort INTERRUPTED snapshot, terminate_all + # escalates to SIGKILL) so a wedged drain can't hang the + # abort. + sigint.bind_session(session, pipe.abandon_drain) phases = _build_phases(ctx, perf_strategy=agentic_inference_strategy) max_duration_ms = ( @@ -913,7 +893,16 @@ def _on_global_timeout() -> None: loop, max_duration_ms, _on_global_timeout ) - _on_phase_start = _make_phase_start_hook(profiler, perf_timeout) + def _on_phase_start(phase: PhaseConfig) -> None: + if phase.phase_type == PhaseType.PERFORMANCE: + # Fire /start_profile sequentially before any perf + # request is issued, so the server is armed when + # traffic begins. + profiler.start() + # Arms the perf cap on PERFORMANCE and cancels it when any + # later phase starts, so a combined perf+accuracy run can + # never have its accuracy phase truncated by the perf cap. + perf_timeout.on_phase_start(phase.phase_type) try: # A pre-session fire already stopped the session inside @@ -961,46 +950,38 @@ def _on_global_timeout() -> None: # Unifies the clean phase-end path and the abort path — both # reach this block. A watchdog abort counts as an abort even # when session.run returned normally after session.stop(). - # Skipped on force-quit: no blocking HTTP on the way out. - if not (sigint is not None and sigint.forced): - await profiler.stop( - session_completed_normally and not watchdog.fired - ) - if sigint is not None and sigint.forced: - # Second ^C: abandon the drain entirely — SIGKILL the - # service children now so the pipeline __aexit__ has - # nothing left to wait for. No report, no metrics - # salvage; the run exits 130 immediately. - pipe.kill_now() - else: - # Graceful drain runs on both the clean-finish and - # session-failure paths (BenchmarkSession.run publishes - # ENDED in its own finally, so a failed run still has a - # terminal snapshot worth draining). Nulls - # pipe.publisher so __aexit__ releases the ZMQ scope - # without killing the services. - try: - report = await pipe.drain_and_build_report() - if report is None: - raise ExecutionError( - "Benchmark completed without a usable " - "metrics report" - ) - except Exception as e: # noqa: BLE001 - # On a clean run a drain / report-build failure must - # be loud: silently returning report=None would exit - # 0 with no perf artifacts. On the session-failure - # path the run is already raising, so swallow it - # there rather than let a teardown error replace the - # in-flight exception; run_benchmark still fails the - # run on a missing report. - if session_completed_normally: - raise - logger.warning( - "Drain/report build error suppressed (run " - "already failing): %s", - e, + profiler.stop(session_completed_normally and not watchdog.fired) + # Graceful drain runs on both the clean-finish and + # session-failure paths (BenchmarkSession.run publishes + # ENDED in its own finally, so a failed run still has a + # terminal snapshot worth draining). Nulls pipe.publisher + # so __aexit__ releases the ZMQ scope without killing the + # services. + try: + report = await pipe.drain_and_build_report() + if report is None: + raise ExecutionError( + "Benchmark completed without a usable " "metrics report" ) + except Exception as e: # noqa: BLE001 + # On a clean run a drain / report-build failure must + # be loud: silently returning report=None would exit + # 0 with no perf artifacts. On the session-failure + # path the run is already raising, so swallow it + # there rather than let a teardown error replace the + # in-flight exception; run_benchmark still fails the + # run on a missing report. + if session_completed_normally: + raise + logger.warning( + "Drain/report build error suppressed (run " + "already failing): %s", + e, + ) + if sigint is not None: + # Drain finished (or failed) on its own — the teardown + # grace timer has nothing left to bound. + sigint.cancel_grace() finally: # Runs on every path, including a setup error before session.run # (which never reaches the session finally above). pbar.close() is @@ -1015,23 +996,13 @@ def _on_global_timeout() -> None: except Exception as e: # noqa: BLE001 — progress bar is cosmetic logger.warning("Progress bar close error: %s", e) if http_client is not None: - if sigint is not None and sigint.forced: - # Second ^C: SIGKILL the worker processes — no graceful - # wait, no transport teardown; the process is exiting. - http_client.kill_workers() - else: - try: - await http_client.shutdown_async() - except Exception as e: # noqa: BLE001 — best-effort; idempotent - logger.warning(f"Client cleanup error: {e}") + try: + await http_client.shutdown_async() + except Exception as e: # noqa: BLE001 — best-effort; idempotent + logger.warning(f"Client cleanup error: {e}") except BaseException as e: - # Force-quit wins over exception identity: once the user pressed ^C - # twice, the exit is theirs no matter what the cancellation unwound - # into on its way out. - forced_quit = sigint is not None and sigint.forced - # Force-quit abandons even the tmpfs salvage; every other abnormal - # path preserves the event log. - if tmpfs_dir.exists() and not forced_quit: + # Abnormal unwind: preserve the event log before re-raising. + if tmpfs_dir.exists(): try: _salvage_tmpfs(ctx.report_dir, tmpfs_dir) shutil.rmtree(tmpfs_dir, ignore_errors=True) @@ -1041,16 +1012,6 @@ def _on_global_timeout() -> None: salvage_err, tmpfs_dir, ) - if forced_quit: - # Second ^C: the pipeline __aexit__ above already SIGKILLed the - # service children (force_quit predicate); kill the HTTP workers - # too in case the cancellation landed inside their graceful - # shutdown await. Both are idempotent. Surface as the user's - # Ctrl-C, not a bare cancellation. - pipe.kill_now() - if http_client is not None: - http_client.kill_workers() - raise KeyboardInterrupt from e if watchdog.fired and isinstance(e, Exception | asyncio.CancelledError): # The watchdog aborted the run: the pre-session fire cancels this # task, and a mid-teardown fire can surface as a launch/drain @@ -1255,10 +1216,12 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # is written in the `finally` below so a scoring failure (e.g. lcb-service # unreachable, missing eval subproject, bad extras) still leaves the perf # run's result_summary.json / report.txt on disk instead of discarding them — - # then the exception propagates as before. The same holds for a ^C landing - # here (the governor raises KeyboardInterrupt mid-scoring once the run task - # is done): the run's measurement genuinely completed, so the completed perf - # artifacts are written as-is and the interrupt propagates for exit 130. + # then the exception propagates as before. A ^C landing here (the governor + # raises KeyboardInterrupt mid-scoring once the run task is done) is + # different: a user abort makes the whole run invalid, so the report is + # rewritten interrupted/complete:false before the `finally` persists it — + # the metrics stay in the file as partial diagnostics — and the interrupt + # propagates for exit 130. accuracy_scores: list[dict[str, Any]] = [] try: if aborted: @@ -1272,6 +1235,12 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: ) else: accuracy_scores = score_accuracy(ctx, result) + except KeyboardInterrupt: + if report is not None: + report = msgspec.structs.replace( + report, complete=False, state="interrupted" + ) + raise finally: # Attach the per-dataset accuracy list so result_summary.json, the # console summary, and report.txt all carry it (stays [] on a scoring diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index 75e441308..f16b6136a 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -43,7 +43,6 @@ import json import logging import uuid -from collections.abc import Callable from pathlib import Path from types import TracebackType from typing import TYPE_CHECKING, Any @@ -213,7 +212,6 @@ def __init__( event_log_dir: Path, metrics_output_dir: Path, loop: asyncio.AbstractEventLoop, - force_quit: Callable[[], bool] | None = None, ) -> None: self._config = config self._tokenizer_name = tokenizer_name @@ -221,10 +219,6 @@ def __init__( self._event_log_dir = event_log_dir self._metrics_output_dir = metrics_output_dir self._loop = loop - # Force-quit predicate (second ^C): when true at teardown time the - # services are SIGKILLed with no SIGTERM grace — teardown must not - # wait on anything. - self._force_quit = force_quit if force_quit is not None else lambda: False self._stack: contextlib.ExitStack | None = None self._launcher: ServiceLauncher | None = None @@ -261,8 +255,8 @@ async def __aexit__( self.publisher is not None or exc_type is not None ): # Register this last so it runs first. ExitStack still executes the - # publisher/subscriber/ZMQ callbacks if terminate_all raises BaseException - # (for example, a second Ctrl-C during teardown). + # publisher/subscriber/ZMQ callbacks if terminate_all raises + # BaseException (for example, a KeyboardInterrupt during teardown). stack.callback(self._kill_services) return stack.__exit__(exc_type, exc, tb) @@ -346,9 +340,9 @@ async def drain_and_build_report(self) -> Report | None: ) # Null the publisher before closing it so __aexit__ sees "drain initiated" # regardless of whether the subsequent await completes or is cancelled - # (e.g. second Ctrl-C). If we close first and then CancelledError fires - # before the null assignment, __aexit__ would call terminate_all() on an - # aggregator that is already draining and writing final_snapshot.json. + # (e.g. the run watchdog firing). If we close first and then CancelledError + # fires before the null assignment, __aexit__ would call terminate_all() + # on an aggregator that is already draining and writing final_snapshot.json. publisher, self.publisher = self.publisher, None publisher.close() logger.info("Waiting for services to finish processing...") @@ -386,35 +380,27 @@ def terminate_metrics_aggregator(self) -> None: return self._launcher.terminate_module(_AGGREGATOR_MODULE) - def kill_now(self) -> None: - """SIGKILL every service child immediately; safe no-op before launch. + def abandon_drain(self) -> None: + """SIGTERM→SIGKILL every service child; safe no-op before/after launch. - Force-quit path (second ^C): no SIGTERM grace, no drain — the - aggregator's INTERRUPTED snapshot and the event logger's buffer are - deliberately abandoned. Idempotent: dead children are skipped. + Teardown-grace path (^C with a wedged drain): SIGTERM gives the + aggregator its chance to write an INTERRUPTED snapshot, SIGKILL reaps + it regardless, and the drain's ``wait_for_exit`` thread unblocks once + the children are gone. Idempotent — exited children are skipped. """ - if self._launcher is None: - return - try: - self._launcher.kill_all() - except Exception as e: # noqa: BLE001 — teardown best-effort - logger.warning("Service kill_all error: %s", e) + self._kill_services() def _kill_services(self) -> None: """Best-effort service termination owned by the pipeline ExitStack. Sends SIGTERM first so the metrics aggregator can flush an INTERRUPTED final_snapshot.json via its signal handler, escalating to SIGKILL after - a short timeout. Under force-quit (second ^C) it SIGKILLs immediately — - no grace, no snapshot. + a short timeout. """ if self._launcher is None: return try: - if self._force_quit(): - self._launcher.kill_all() - else: - self._launcher.terminate_all() + self._launcher.terminate_all() except Exception as e: # noqa: BLE001 — teardown best-effort logger.warning("Service termination error: %s", e) diff --git a/src/inference_endpoint/commands/benchmark/profiling.py b/src/inference_endpoint/commands/benchmark/profiling.py index 65839c612..60db572e3 100644 --- a/src/inference_endpoint/commands/benchmark/profiling.py +++ b/src/inference_endpoint/commands/benchmark/profiling.py @@ -24,7 +24,6 @@ from __future__ import annotations -import asyncio import logging import time from datetime import datetime @@ -171,15 +170,10 @@ def __init__( self._start_urls = _derive_profile_urls(profile_endpoints, engine, "start") self._stop_urls = _derive_profile_urls(profile_endpoints, engine, "stop") - async def start(self) -> None: - """Fire /start_profile sequentially before any perf request is issued. - - Each POST runs in a worker thread so the event-loop thread never - blocks — a force-quit ``task.cancel`` lands between POSTs instead of - waiting out the full ``timeout × endpoints`` budget. - """ + def start(self) -> None: + """Fire /start_profile sequentially before any perf request is issued.""" for url in self._start_urls: - rec = await asyncio.to_thread(_post_profile, url) + rec = _post_profile(url) if rec["status"] == 200: logger.info("Profile start: %s -> 200 OK", url) else: @@ -188,11 +182,10 @@ async def start(self) -> None: ) self._starts.append(rec) - async def stop(self, completed_normally: bool) -> None: + def stop(self, completed_normally: bool) -> None: """Fire /stop_profile for every start that returned 200. Unifies the clean phase-end path and the abort path — both call this. - POSTs run in worker threads (see ``start``). """ if not self._starts: return @@ -200,7 +193,7 @@ async def stop(self, completed_normally: bool) -> None: for i, start_rec in enumerate(self._starts): if start_rec["status"] != 200 or i >= len(self._stop_urls): continue - rec = await asyncio.to_thread(_post_profile, self._stop_urls[i]) + rec = _post_profile(self._stop_urls[i]) rec["stop_reason"] = stop_reason if rec["status"] == 200: logger.info("Profile stop: %s -> 200 OK", self._stop_urls[i]) diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index 6234732dc..760fec68f 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -46,69 +46,75 @@ class SigintGovernor: whose gaps are exactly where a ^C used to slip through as a raw KeyboardInterrupt and abort teardown half-way. - Semantics: + One behavior, keystroke-count-independent (so runners that forward the + terminal's group SIGINT, like ``uv run``, need no special handling): - ^C with no live run (sync setup, finalization after the loop returned, between audit phases): nothing to stop gracefully — raise KeyboardInterrupt immediately (default behavior, exit 130). - - First ^C: graceful — ``session.stop()``; the stopped run publishes - INTERRUPTED+ENDED, services drain (including the metrics-tokenization - backlog), artifacts land as state=interrupted, then ``run_benchmark`` - raises for exit 130. - - Any further ^C: FORCE QUIT — the run task is cancelled, the service - children and HTTP workers are SIGKILLed, the metrics drain and tmpfs - salvage are abandoned, exit 130. - - One keystroke counts once: process runners that forward the terminal's - group SIGINT to their child (``uv run`` does) deliver a single ^C twice — - the kernel coalesces near-simultaneous deliveries, the forwarded copy can - land a few hundred ms later (~200 ms measured for ``uv run``). Deliveries - within ``_DUP_DELIVERY_WINDOW_S`` of the last accepted one are dropped as - duplicates; a deliberate later press always forces. + - ^C with a live run: graceful — ``session.stop()``; the stopped run + publishes INTERRUPTED+ENDED, services drain, artifacts land as + state=interrupted, then ``run_benchmark`` raises for exit 130. A + teardown grace timer is armed: if the metrics drain has not finished + within ``TEARDOWN_GRACE_S``, the aggregator is SIGTERMed (its handler + writes a best-effort INTERRUPTED snapshot; ``terminate_all`` escalates + to SIGKILL) so a wedged drain can never hang the abort. + - Any repeat ^C: logged no-op — the stop is already in flight and the + grace timer bounds the teardown. """ - _DUP_DELIVERY_WINDOW_S = 1.0 + TEARDOWN_GRACE_S = 30.0 + """Seconds after a ^C before a still-running metrics drain is abandoned.""" def __init__(self) -> None: self.interrupted = False - self.forced = False self._session: BenchmarkSession | None = None self._task: asyncio.Task | None = None self._loop: asyncio.AbstractEventLoop | None = None - self._last_accepted_monotonic = float("-inf") + self._on_grace_expiry: Callable[[], None] | None = None + self._grace_handle: asyncio.TimerHandle | None = None def bind_task( self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop ) -> None: - """Bind the run coroutine's task — the force-quit cancellation target.""" + """Bind the run coroutine's task — the live-run gate for the graceful path.""" self._task = task self._loop = loop - def bind_session(self, session: BenchmarkSession) -> None: + def bind_session( + self, session: BenchmarkSession, on_grace_expiry: Callable[[], None] + ) -> None: self._session = session + self._on_grace_expiry = on_grace_expiry + + def cancel_grace(self) -> None: + """Disarm the teardown grace timer (drain finished on its own).""" + if self._grace_handle is not None: + self._grace_handle.cancel() + self._grace_handle = None + + def _stop_gracefully(self) -> None: + """Runs on the loop: stop the session and bound the teardown.""" + assert self._session is not None and self._loop is not None + self._session.stop() + if self._on_grace_expiry is not None and self._grace_handle is None: + + def _expire() -> None: + logger.warning( + "Teardown did not finish within %.0fs of ^C — abandoning " + "the metrics drain", + self.TEARDOWN_GRACE_S, + ) + assert self._on_grace_expiry is not None + self._on_grace_expiry() + + self._grace_handle = self._loop.call_later(self.TEARDOWN_GRACE_S, _expire) def __call__(self, signum: int, frame: types.FrameType | None) -> None: - now = time.monotonic() - if now - self._last_accepted_monotonic < self._DUP_DELIVERY_WINDOW_S: - # Same keystroke, second delivery (group SIGINT + a forwarding - # runner like `uv run`) — not a user escalation. - return - self._last_accepted_monotonic = now if self.interrupted: - self.forced = True - logger.warning( - "SIGINT again: force quit — abandoning teardown/metrics drain" - ) - if ( - self._task is not None - and not self._task.done() - and self._loop is not None - and self._loop.is_running() - ): - # Cancelling the run task unwinds its finallys: the pipeline - # __aexit__ kills the service children and tmpfs is salvaged. - self._loop.call_soon_threadsafe(self._task.cancel) - return - raise KeyboardInterrupt + # Stop already in flight; the grace timer bounds the teardown. A + # forwarded duplicate delivery (uv run) lands here harmlessly too. + logger.warning("SIGINT again: shutdown already in progress") + return self.interrupted = True if ( self._session is None @@ -121,14 +127,12 @@ def __call__(self, signum: int, frame: types.FrameType | None) -> None: # stopped loop would queue session.stop and never run it — # silently swallowing the ^C. raise KeyboardInterrupt - logger.warning( - "SIGINT received: stopping benchmark gracefully (^C again to force)" - ) + logger.warning("SIGINT received: stopping benchmark gracefully") # A signal handler runs at an arbitrary bytecode boundary — possibly # mid-event-loop-iteration. Don't mutate asyncio state (Event.set, - # Task.cancel) from here; hand session.stop to the loop, the one - # asyncio entry point documented as signal-handler safe. - self._loop.call_soon_threadsafe(self._session.stop) + # call_later) from here; hand the stop to the loop, the one asyncio + # entry point documented as signal-handler safe. + self._loop.call_soon_threadsafe(self._stop_gracefully) class PerfPhaseTimeout: diff --git a/src/inference_endpoint/endpoint_client/http_client.py b/src/inference_endpoint/endpoint_client/http_client.py index c420bf356..e273f5814 100644 --- a/src/inference_endpoint/endpoint_client/http_client.py +++ b/src/inference_endpoint/endpoint_client/http_client.py @@ -162,16 +162,6 @@ async def shutdown_async(self) -> None: return await self._shutdown_async() - def kill_workers(self) -> None: - """SIGKILL every worker process immediately — force-quit path. - - Synchronous and loop-free (callable during teardown of a cancelled - task): no graceful wait, no transport cleanup. Marks the client shut - down so a later graceful call is a no-op. - """ - self._shutdown = True - self.worker_manager.kill_now() - async def _shutdown_async(self) -> None: """Async shutdown internals - must be called on the event loop.""" self._shutdown = True diff --git a/src/inference_endpoint/endpoint_client/worker.py b/src/inference_endpoint/endpoint_client/worker.py index d5ce864c5..ec49a71b0 100644 --- a/src/inference_endpoint/endpoint_client/worker.py +++ b/src/inference_endpoint/endpoint_client/worker.py @@ -46,7 +46,6 @@ PooledConnection, ) from inference_endpoint.profiling import profile -from inference_endpoint.profiling import shutdown as profiling_shutdown from inference_endpoint.utils.logging import setup_logging logger = logging.getLogger(__name__) @@ -123,10 +122,6 @@ def worker_main( except Exception as e: logger.error(f"Crashed: {type(e).__name__}: {str(e)}\n{traceback.format_exc()}") sys.exit(1) - finally: - # Dump this worker's line-profiler stats to its per-PID logfile - # before the process exits (no-op unless ENABLE_LINE_PROFILER=1). - profiling_shutdown() class Worker: diff --git a/src/inference_endpoint/endpoint_client/worker_manager.py b/src/inference_endpoint/endpoint_client/worker_manager.py index 967f8999d..ae0d194df 100644 --- a/src/inference_endpoint/endpoint_client/worker_manager.py +++ b/src/inference_endpoint/endpoint_client/worker_manager.py @@ -158,16 +158,6 @@ async def _wait_for_workers_with_liveness_check(self) -> None: except TimeoutError: continue # Loop to check liveness again - def kill_now(self) -> None: - """SIGKILL every worker immediately — force-quit path (second ^C). - - No graceful terminate, no join, no transport cleanup: the parent - process is exiting and the kernel reaps the SIGKILLed children. - """ - for worker in self.workers: - if worker.is_alive(): - worker.kill() - async def shutdown(self) -> None: """Shutdown workers and transports.""" # Terminate workers diff --git a/src/inference_endpoint/load_generator/session.py b/src/inference_endpoint/load_generator/session.py index 5da8f56f6..5f99e39ad 100644 --- a/src/inference_endpoint/load_generator/session.py +++ b/src/inference_endpoint/load_generator/session.py @@ -24,7 +24,7 @@ import logging import time import uuid -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum from typing import Any, Protocol @@ -425,7 +425,7 @@ def stop_current_phase(self) -> None: async def run( self, phases: list[PhaseConfig], - on_phase_start: Callable[[PhaseConfig], Awaitable[None]] | None = None, + on_phase_start: Callable[[PhaseConfig], None] | None = None, ) -> SessionResult: """Run all benchmark phases sequentially. @@ -442,7 +442,7 @@ async def run( if self._stop_requested: break if on_phase_start is not None: - await on_phase_start(phase) + on_phase_start(phase) result = await self._run_phase(phase) if result is not None: phase_results.append(result) diff --git a/src/inference_endpoint/main.py b/src/inference_endpoint/main.py index 05161e91c..abae50643 100644 --- a/src/inference_endpoint/main.py +++ b/src/inference_endpoint/main.py @@ -42,7 +42,6 @@ InputValidationError, SetupError, ) -from inference_endpoint.profiling import shutdown as profiling_shutdown from inference_endpoint.utils.logging import setup_logging logger = logging.getLogger(__name__) @@ -153,10 +152,6 @@ def run() -> None: except Exception: traceback.print_exc() sys.exit(1) - finally: - # Dump any pending line-profiler stats before the process exits - # (no-op unless ENABLE_LINE_PROFILER=1). - profiling_shutdown() if __name__ == "__main__": diff --git a/src/inference_endpoint/profiling/line_profiler.py b/src/inference_endpoint/profiling/line_profiler.py index 910eb66c5..56c2d659e 100644 --- a/src/inference_endpoint/profiling/line_profiler.py +++ b/src/inference_endpoint/profiling/line_profiler.py @@ -20,10 +20,10 @@ - Controlled via ENABLE_LINE_PROFILER environment variable - No-op decorators when disabled (zero overhead) - Support for both sync and async functions -- Stats are dumped by an explicit ``shutdown()`` at each process's exit - point (CLI ``run()``, ``worker_main``, pytest sessionfinish) — no atexit +- Automatic cleanup on process exit """ +import atexit import contextlib import io import os @@ -70,6 +70,7 @@ def __init__(self): self._stats_printed = False logfile = os.environ.get(ENV_VAR_LINE_PROFILER_LOGFILE, None) self.output_file = Path(logfile) if logfile else None + self._atexit_registered = False if self.enabled: if LineProfiler is None: @@ -79,6 +80,34 @@ def __init__(self): ) self.profiler = LineProfiler() self.profiler.enable() + atexit.register(self._safe_cleanup) + self._atexit_registered = True + + def _safe_cleanup(self): + """Safe cleanup wrapper that suppresses all errors during atexit.""" + if not self._atexit_registered: + return + + try: + self._cleanup() + except: # noqa: E722 + pass # Suppress all errors during shutdown + + def _cleanup(self): + """Cleanup function called at interpreter exit or explicit shutdown. + + Prints stats (if any) and then completely tears down the profiler + to prevent shutdown errors. + """ + if not self.profiler or self._stats_printed or not self.profiler.functions: + self._teardown_profiler() + return + + with contextlib.suppress(Exception): + self.pause() + self._print_stats_to_destination() + self._stats_printed = True + self._teardown_profiler() def _print_stats_to_destination(self): """Print stats to configured output destination.""" @@ -97,13 +126,7 @@ def _teardown_profiler(self): if not self.profiler: return - try: - self.profiler.disable() - except ValueError: - # Already disabled: line_profiler releases its sys.monitoring - # tool id on disable, and a second disable (e.g. after a stats - # snapshot) raises. The teardown below must still run. - pass + self.profiler.disable() self.profiler.functions.clear() self.profiler.enable_count = 0 self.profiler = None @@ -159,21 +182,12 @@ def pause(self): pass # Already torn down def shutdown(self): - """Print pending stats and tear down. Safe to call multiple times. - - Teardown runs unconditionally: ``_stats_printed`` only suppresses a - duplicate dump (e.g. ``print_stats()`` already ran), and a failing - output destination must still leave the C profiler disabled. - """ - if not self.profiler: + """Explicit shutdown for worker processes. Safe to call multiple times.""" + if self._stats_printed: return - try: - if not self._stats_printed and self.profiler.functions: - with contextlib.suppress(Exception): - self.pause() - self._print_stats_to_destination() - finally: - self._teardown_profiler() + + self._atexit_registered = False # Prevent double-printing via atexit + self._cleanup() def is_enabled(self) -> bool: """Check if profiling is currently enabled.""" diff --git a/src/inference_endpoint/profiling/pytest_profiling_plugin.py b/src/inference_endpoint/profiling/pytest_profiling_plugin.py index b077128cc..3a2680612 100644 --- a/src/inference_endpoint/profiling/pytest_profiling_plugin.py +++ b/src/inference_endpoint/profiling/pytest_profiling_plugin.py @@ -23,6 +23,7 @@ - Ensures clean output even on test failures """ +import atexit import glob import os import shutil @@ -49,6 +50,9 @@ def pytest_configure(config): "/tmp/mlperf_client_profiles/profile" ) + # Suppress stderr during interpreter shutdown to hide line_profiler internal errors + atexit.register(_suppress_stderr_during_shutdown) + def pytest_sessionfinish(session, exitstatus): """Print profiling results after test session completes.""" @@ -104,3 +108,14 @@ def _cleanup_profile_files(output_file: str): shutil.rmtree(profile_dir, ignore_errors=True) except Exception: pass # Silently fail cleanup + + +def _suppress_stderr_during_shutdown(): + """Suppress stderr at OS level to hide harmless line_profiler shutdown errors.""" + try: + # Redirect stderr file descriptor to /dev/null + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, 2) + os.close(devnull) + except Exception: + pass # Silently fail if stderr redirection fails diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 6977412c2..7bfc3105d 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -208,24 +208,38 @@ def test_sigint_mid_run_exits_130_with_interrupted_artifacts( @pytest.mark.integration -def test_second_sigint_force_quits_immediately(mock_http_echo_server, tmp_path): - """Second ^C abandons a wedged metrics drain and exits 130 promptly. +def test_sigint_grace_expiry_abandons_wedged_drain(mock_http_echo_server, tmp_path): + """A single ^C against a wedged metrics drain exits within the grace. The aggregator child is SIGSTOPped to simulate a wedged drain — the exact - hang the force-quit path exists for. SIGINT goes to the MAIN process only - (``os.kill``, not the group), as the governor's contract is per-process: - the first ^C stops the session gracefully and then parks forever waiting - for the stopped aggregator; the second ^C must SIGKILL the children and - exit 130 within seconds. + hang the teardown grace exists for. One SIGINT to the MAIN process only + (``os.kill``, not the group): the graceful stop parks on the wedged + drain; grace expiry must SIGTERM→SIGKILL the children so the drain's + wait-for-exit unblocks and the run exits 130 without a second keystroke. + The grace is shrunk to 3s via the class constant (fixed 30s in + production) so the test stays fast. """ report_dir = tmp_path / "report" config_path = tmp_path / "bench.yaml" _write_config(report_dir, mock_http_echo_server.url, config_path) + wrapper = ( + "from inference_endpoint.commands.benchmark.watchdog import SigintGovernor; " + "SigintGovernor.TEARDOWN_GRACE_S = 3.0; " + "from inference_endpoint.main import run; run()" + ) agg_pid: int | None = None try: with _benchmark_proc( - [_cli(), "benchmark", "from-config", "-c", str(config_path)] + [ + shutil.which("python") or "python", + "-c", + wrapper, + "benchmark", + "from-config", + "-c", + str(config_path), + ] ) as proc: _wait_services_ready(proc, report_dir) time.sleep(3.0) # comfortably inside the ~120 s performance phase @@ -234,26 +248,23 @@ def test_second_sigint_force_quits_immediately(mock_http_echo_server, tmp_path): assert agg_pid is not None, "aggregator child not found" os.kill(agg_pid, signal.SIGSTOP) # wedge the drain - os.kill(proc.pid, signal.SIGINT) - time.sleep(2.0) # graceful path engaged; drain parked on the wedge - assert proc.poll() is None, "first ^C must keep waiting on the drain" - os.kill(proc.pid, signal.SIGINT) start = time.monotonic() - rc = proc.wait(timeout=15.0) - force_quit_latency = time.monotonic() - start + rc = proc.wait(timeout=30.0) + abort_latency = time.monotonic() - start finally: if agg_pid is not None: try: os.kill(agg_pid, signal.SIGKILL) # SIGKILL reaps stopped procs except ProcessLookupError: - pass # already gone — the force-quit killed it + pass # already gone — grace escalation killed it - assert rc == 130, f"force quit must exit 130, got {rc}" + assert rc == 130, f"^C must exit 130, got {rc}" + assert abort_latency > 2.0, "exited before the grace — drain was not wedged" assert ( - force_quit_latency < 10.0 - ), f"force quit took {force_quit_latency:.1f}s — the drain was not abandoned" - _assert_no_leftover_children(report_dir, "force quit") + abort_latency < 20.0 + ), f"abort took {abort_latency:.1f}s — grace escalation did not fire" + _assert_no_leftover_children(report_dir, "grace-expired abort") @pytest.mark.integration @@ -280,12 +291,12 @@ def test_sigint_before_session_exits_130(mock_http_echo_server, tmp_path): @pytest.mark.integration def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp_path): - """One keystroke under `uv run` counts once. + """One keystroke under `uv run` stays graceful. `uv run` forwards the terminal's group SIGINT to its child, so a single ^C is delivered twice (~200 ms apart, past kernel coalescing). The - duplicate must be suppressed: the run takes the graceful path — report - written, exit 130 — instead of force-quitting and losing the metrics. + duplicate is a harmless no-op (the stop is already in flight): the run + takes the graceful path — report written, exit 130. """ uv = shutil.which("uv") if uv is None: @@ -312,7 +323,7 @@ def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp rc = proc.wait(timeout=60.0) assert rc == 130, f"user abort must exit 130, got {rc}" - # The graceful path writes the report; a force quit would have skipped it. + # The graceful path writes the report before exiting. _assert_interrupted_artifacts(report_dir) diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 6140a6489..53389676a 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -2277,17 +2277,16 @@ def test_aborted_run_never_writes_complete_artifacts(self, tmp_path, abort_field assert summary["state"] == "interrupted" @pytest.mark.unit - def test_sigint_during_scoring_keeps_completed_perf_artifacts( - self, tmp_path, monkeypatch - ): - """^C in the finalization window: completed perf artifacts survive. - - The run genuinely completed (no abort flag) and the governor's - KeyboardInterrupt lands mid-scoring. The interrupt must propagate - (main.py exits 130) but the finally still writes the perf report as - complete:true — the measurement finished; only post-measurement - scoring was aborted. This is the documented exception to "a ^C'd - run's summary is never complete" (CLI_QUICK_REFERENCE "Ctrl-C"). + def test_sigint_during_scoring_invalidates_run(self, tmp_path, monkeypatch): + """^C in the finalization window: the run becomes invalid. + + The measurement completed (no abort flag) and the governor's + KeyboardInterrupt lands mid-scoring. A user abort makes the whole run + invalid: the interrupt must propagate (main.py exits 130) and the + finally must persist the report as interrupted/complete:false — the + metrics stay in the file as partial diagnostics, never as a + submittable result. An ordinary scoring *failure* (Exception) keeps + the completed report; only a user abort invalidates it. """ config = OfflineConfig(**_OFFLINE_KWARGS) ctx = _make_benchmark_context(config=config, report_dir=tmp_path) @@ -2305,8 +2304,8 @@ def test_sigint_during_scoring_keeps_completed_perf_artifacts( summary = json.loads( (tmp_path / "performance" / "result_summary.json").read_text() ) - assert summary["complete"] is True - assert summary["state"] == "complete" + assert summary["complete"] is False + assert summary["state"] == "interrupted" @staticmethod def _make_complete_report() -> Report: @@ -2851,51 +2850,6 @@ def test_logs_external_sample_count_for_skip_endpoint_phase_scorer( ) -class TestPhaseStartHook: - """_make_phase_start_hook ordering: profiler armed before the perf cap. - - The order is load-bearing — _run_phase clears the phase-stop flag at - entry, so a perf cap armed before the awaited profile arming could fire - during the await and be silently erased (phase runs uncapped). - """ - - @pytest.mark.unit - @pytest.mark.asyncio - async def test_profiler_start_completes_before_perf_cap_arms(self): - order: list[str] = [] - profiler = MagicMock() - - async def _start() -> None: - await asyncio.sleep(0) # a real suspend, like the to_thread POSTs - order.append("profiler.start") - - profiler.start = _start - perf_timeout = MagicMock() - perf_timeout.on_phase_start.side_effect = lambda pt: order.append("cap.armed") - - hook = execute_mod._make_phase_start_hook(profiler, perf_timeout) - await hook(MagicMock(phase_type=PhaseType.PERFORMANCE)) - - assert order == ["profiler.start", "cap.armed"] - perf_timeout.on_phase_start.assert_called_once_with(PhaseType.PERFORMANCE) - - @pytest.mark.unit - @pytest.mark.asyncio - @pytest.mark.parametrize("phase_type", [PhaseType.WARMUP, PhaseType.ACCURACY]) - async def test_non_performance_phase_skips_profiler_still_arms_timer( - self, phase_type - ): - profiler = MagicMock() - perf_timeout = MagicMock() - - hook = execute_mod._make_phase_start_hook(profiler, perf_timeout) - await hook(MagicMock(phase_type=phase_type)) - - profiler.start.assert_not_called() - # on_phase_start cancels the perf timer for non-PERFORMANCE phases. - perf_timeout.on_phase_start.assert_called_once_with(phase_type) - - class TestProfilingHelpers: @pytest.mark.unit @pytest.mark.parametrize( @@ -3001,8 +2955,7 @@ def test_write_section_and_json_roundtrip(self): assert json.loads(json.dumps(payload))["engine"] == "vllm" @pytest.mark.unit - @pytest.mark.asyncio - async def test_controller_start_then_stop_maps_indices(self): + def test_controller_start_then_stop_maps_indices(self): """start() posts each /start_profile; stop() posts /stop_profile only for the starts that returned 200, mapped by the same index, tagging stop_reason.""" @@ -3024,8 +2977,8 @@ def _fake_post(url): ctrl = ProfileController( ProfilerEngine.VLLM, ["http://a/v1", "http://b/v1"], None ) - await ctrl.start() - await ctrl.stop(completed_normally=True) + ctrl.start() + ctrl.stop(completed_normally=True) payload = ctrl.payload() assert payload["engine"] == "vllm" @@ -3038,8 +2991,7 @@ def _fake_post(url): assert payload["stops"][0]["stop_reason"] == "phase_end" @pytest.mark.unit - @pytest.mark.asyncio - async def test_controller_stop_reason_abort_when_not_completed(self): + def test_controller_stop_reason_abort_when_not_completed(self): with patch( "inference_endpoint.commands.benchmark.profiling._post_profile", side_effect=lambda url: { @@ -3051,28 +3003,26 @@ async def test_controller_stop_reason_abort_when_not_completed(self): }, ): ctrl = ProfileController(ProfilerEngine.VLLM, ["http://a/v1"], None) - await ctrl.start() - await ctrl.stop(completed_normally=False) + ctrl.start() + ctrl.stop(completed_normally=False) assert ctrl.payload()["stops"][0]["stop_reason"] == "abort" @pytest.mark.unit - @pytest.mark.asyncio - async def test_controller_disabled_is_noop(self): + def test_controller_disabled_is_noop(self): """engine=None → no URLs derived, start/stop do nothing, payload is None.""" ctrl = ProfileController(None, ["http://a/v1"], None) - await ctrl.start() - await ctrl.stop(completed_normally=True) + ctrl.start() + ctrl.stop(completed_normally=True) assert ctrl.payload() is None @pytest.mark.unit - @pytest.mark.asyncio - async def test_controller_stop_without_start_posts_nothing(self): + def test_controller_stop_without_start_posts_nothing(self): """stop() before any start() records nothing (empty _starts, early return).""" with patch( "inference_endpoint.commands.benchmark.profiling._post_profile", ) as mock_post: ctrl = ProfileController(ProfilerEngine.VLLM, ["http://a/v1"], None) - await ctrl.stop(completed_normally=True) + ctrl.stop(completed_normally=True) mock_post.assert_not_called() assert ctrl.payload()["stops"] == [] diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index cf6660d32..63eaee85e 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -13,15 +13,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""SigintGovernor state machine: graceful vs force vs no-live-run paths.""" +"""SigintGovernor (graceful stop + teardown grace) and PerfPhaseTimeout.""" from __future__ import annotations import asyncio -import contextlib -import itertools import signal -import time from unittest.mock import MagicMock import pytest @@ -36,15 +33,15 @@ def _fire(gov: SigintGovernor) -> None: gov(signal.SIGINT, None) -def _distinct_fire(gov: SigintGovernor) -> None: - """A ^C outside the duplicate-delivery window (a deliberate press).""" - gov._last_accepted_monotonic = float("-inf") - _fire(gov) - - @pytest.mark.unit class TestSigintGovernor: - def test_first_sigint_after_loop_returned_raises_immediately(self): + def test_unbound_sigint_raises_keyboard_interrupt(self): + gov = SigintGovernor() + with pytest.raises(KeyboardInterrupt): + _fire(gov) + assert gov.interrupted + + def test_sigint_after_loop_returned_raises_immediately(self): """A ^C during sync finalization must not be swallowed. After ``run_until_complete`` returns, the session/task/loop stay @@ -56,7 +53,7 @@ def test_first_sigint_after_loop_returned_raises_immediately(self): async def run_phase() -> None: gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) - gov.bind_session(session) + gov.bind_session(session, MagicMock()) asyncio.run(run_phase()) @@ -65,88 +62,64 @@ async def run_phase() -> None: assert gov.interrupted session.stop.assert_not_called() - def test_second_distinct_sigint_after_loop_returned_raises(self): - """The force path with a finished task escalates to KeyboardInterrupt.""" + @pytest.mark.asyncio + async def test_live_sigint_stops_session_and_arms_grace(self): gov = SigintGovernor() session = MagicMock() + on_grace = MagicMock() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session, on_grace) - async def run_phase() -> None: - gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) - gov.bind_session(session) + _fire(gov) + await asyncio.sleep(0) # run the queued call_soon_threadsafe - asyncio.run(run_phase()) + assert gov.interrupted + session.stop.assert_called_once() + assert gov._grace_handle is not None + on_grace.assert_not_called() # armed, not fired - with pytest.raises(KeyboardInterrupt): - _fire(gov) - with pytest.raises(KeyboardInterrupt): - _distinct_fire(gov) - assert gov.forced + @pytest.mark.asyncio + async def test_repeat_sigint_is_a_noop(self): + """Any repeat ^C (incl. a forwarded duplicate under `uv run`) is silent.""" + gov = SigintGovernor() + session = MagicMock() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session, MagicMock()) + + _fire(gov) + _fire(gov) + _fire(gov) + await asyncio.sleep(0) + + session.stop.assert_called_once() @pytest.mark.asyncio - @pytest.mark.parametrize("bound", [True, False], ids=["bound", "unbound"]) - @pytest.mark.parametrize( - "deliveries", - [ - seq - for n in (1, 2, 3) - for seq in itertools.product(("distinct", "dup"), repeat=n) - # A duplicate before any accepted delivery cannot occur: the dedup - # window opens on the first accepted ^C. - if seq[0] == "distinct" - ], - ids="-".join, - ) - async def test_delivery_sequences_exhaustive(self, bound, deliveries): - """Every bind-state x delivery-sequence (length <= 3), exhaustively. - - Contract: an accepted, distinct delivery is never silently dropped — - it schedules a graceful stop (first, bound), cancels the live run - task (second, bound), or raises KeyboardInterrupt (unbound). Only - duplicate deliveries inside the window are silent. Escalation to - force happens on exactly the second accepted delivery. - """ + async def test_grace_expiry_fires_callback_once(self): gov = SigintGovernor() + gov.TEARDOWN_GRACE_S = 0.02 # instance override; class default untouched session = MagicMock() - run_task = asyncio.create_task(asyncio.sleep(30)) - await asyncio.sleep(0) # let the child task start - if bound: - gov.bind_task(run_task, asyncio.get_running_loop()) - gov.bind_session(session) - - accepted = 0 - try: - for kind in deliveries: - if kind == "dup": - # Inside the duplicate window of the previous delivery. - gov._last_accepted_monotonic = time.monotonic() - else: - gov._last_accepted_monotonic = float("-inf") - accepted += 1 - if kind == "distinct" and not bound: - with pytest.raises(KeyboardInterrupt): - gov(signal.SIGINT, None) - else: - gov(signal.SIGINT, None) # silent: bound or deduped - - assert gov.interrupted - assert gov.forced == (accepted >= 2) - if bound: - await asyncio.sleep(0) # run queued call_soon_threadsafe work - assert session.stop.call_count == 1 - if accepted >= 2: - # Force path cancelled the run task; let it settle. - with contextlib.suppress(asyncio.CancelledError): - await asyncio.wait_for(run_task, timeout=2.0) - assert run_task.cancelled() - else: - assert not run_task.done() - else: - session.stop.assert_not_called() - assert not run_task.done() - finally: - run_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await asyncio.wait_for(run_task, timeout=2.0) + fired = asyncio.Event() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session, fired.set) + + _fire(gov) + await asyncio.wait_for(fired.wait(), timeout=2.0) + + @pytest.mark.asyncio + async def test_cancel_grace_disarms_pending_timer(self): + gov = SigintGovernor() + gov.TEARDOWN_GRACE_S = 0.02 + session = MagicMock() + on_grace = MagicMock() + gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) + gov.bind_session(session, on_grace) + + _fire(gov) + await asyncio.sleep(0) + gov.cancel_grace() # the drain finished on its own + await asyncio.sleep(0.1) # 5x the grace: a leaked timer would fire + + on_grace.assert_not_called() @pytest.mark.unit diff --git a/tests/unit/load_generator/test_async_session.py b/tests/unit/load_generator/test_async_session.py index 4ec4e04ed..a5e6e3c73 100644 --- a/tests/unit/load_generator/test_async_session.py +++ b/tests/unit/load_generator/test_async_session.py @@ -620,8 +620,8 @@ async def test_stop_current_phase_advances_to_accuracy(self): assert session._stop_requested is False @pytest.mark.asyncio - async def test_async_phase_start_hook_awaited_before_issuing(self): - """``on_phase_start`` is awaited to completion before the phase issues.""" + async def test_phase_start_hook_runs_before_issuing(self): + """``on_phase_start`` runs to completion before the phase issues.""" loop = asyncio.get_running_loop() issuer = FakeIssuer() issuer._loop = loop @@ -630,9 +630,8 @@ async def test_async_phase_start_hook_awaited_before_issuing(self): hook_done = False - async def hook(phase: PhaseConfig) -> None: + def hook(phase: PhaseConfig) -> None: nonlocal hook_done - await asyncio.sleep(0.01) assert issuer._issued == [] hook_done = True diff --git a/tests/unit/test_profiler.py b/tests/unit/test_profiler.py index cdbe648a4..9f98af68c 100644 --- a/tests/unit/test_profiler.py +++ b/tests/unit/test_profiler.py @@ -34,52 +34,55 @@ ENV_VAR_ENABLE_LINE_PROFILER, ) -pytestmark = pytest.mark.unit - @pytest.fixture(autouse=True) -def restore_profiler_singleton(): - """Restore the module-level singleton after any test that replaces it. - - The module's public API (``profile``, ``print_stats``, ...) is bound to - the singleton created at import time; tests that reset ``_instance`` and - re-init under a patched env must not leak that replacement (or a live C - profiler) into other tests. - """ - original = line_profiler.ProfilerState._instance +def cleanup_profiler(): + """Ensure profiler is cleaned up after each test.""" yield - current = line_profiler.ProfilerState._instance - if current is not None and current is not original: - current.shutdown() - line_profiler.ProfilerState._instance = original - -@pytest.fixture -def enabled_profiler(): - """A fresh, enabled ProfilerState under ENABLE_LINE_PROFILER=1.""" - with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): - line_profiler.ProfilerState._instance = None - yield line_profiler.ProfilerState() + # Clean up after test + if ( + line_profiler.ProfilerState._instance + and line_profiler.ProfilerState._instance.profiler + ): + try: + line_profiler.ProfilerState._instance.pause() + # Clear any accumulated stats + line_profiler.ProfilerState._instance._stats_printed = False + except Exception: + pass class TestProfilerState: """Test the ProfilerState singleton.""" def test_singleton_pattern(self): + """Test that ProfilerState follows singleton pattern.""" state1 = line_profiler.ProfilerState() state2 = line_profiler.ProfilerState() assert state1 is state2 def test_profiler_disabled_by_default(self): + """Test profiler is disabled when ENABLE_LINE_PROFILER is not set.""" with mock.patch.dict(os.environ, {}, clear=True): + # Force re-initialization line_profiler.ProfilerState._instance = None state = line_profiler.ProfilerState() assert not state.enabled assert state.profiler is None - def test_profiler_enabled_with_env_var(self, enabled_profiler): - # Only check the enabled flag: line_profiler might not be installed. - assert enabled_profiler.enabled + def test_profiler_enabled_with_env_var(self): + """Test profiler is enabled when ENABLE_LINE_PROFILER=1.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + # Force re-initialization + line_profiler.ProfilerState._instance = None + try: + state = line_profiler.ProfilerState() + # Only check enabled flag, as line_profiler might not be installed + assert state.enabled + finally: + # Reset for other tests + line_profiler.ProfilerState._instance = None class TestProfileDecorators: @@ -87,38 +90,68 @@ class TestProfileDecorators: @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_profile_decorator_sync_when_disabled(self): + """Test profile decorator returns original sync function when disabled.""" + @profile def test_func(x): return x * 2 - # When disabled, decorator is a no-op and the function is unchanged. + # When disabled, decorator should be no-op assert test_func(5) == 10 + # Function should be unchanged assert test_func.__name__ == "test_func" @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_profile_decorator_async_when_disabled(self): + """Test profile decorator returns original async function when disabled.""" + @profile async def test_async_func(x): await asyncio.sleep(0) return x * 2 - assert asyncio.run(test_async_func(5)) == 10 + # When disabled, decorator should be no-op + result = asyncio.run(test_async_func(5)) + assert result == 10 + # Function should be unchanged assert test_async_func.__name__ == "test_async_func" - def test_profile_decorator_sync_when_enabled(self, enabled_profiler): - @enabled_profiler.profile - def test_func(x): - return x * 2 + def test_profile_decorator_sync_when_enabled(self): + """Test profile decorator wraps sync function when enabled.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + # Force re-initialization + line_profiler.ProfilerState._instance = None - assert test_func(5) == 10 + # Import after setting env var + from inference_endpoint.profiling.line_profiler import ProfilerState - def test_profile_decorator_async_when_enabled(self, enabled_profiler): - @enabled_profiler.profile - async def test_async_func(x): - await asyncio.sleep(0) - return x * 2 + state = ProfilerState() - assert asyncio.run(test_async_func(5)) == 10 + @state.profile + def test_func(x): + return x * 2 + + result = test_func(5) + assert result == 10 + + def test_profile_decorator_async_when_enabled(self): + """Test profile decorator wraps async function when enabled.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + # Force re-initialization + line_profiler.ProfilerState._instance = None + + # Import after setting env var + from inference_endpoint.profiling.line_profiler import ProfilerState + + state = ProfilerState() + + @state.profile + async def test_async_func(x): + await asyncio.sleep(0) + return x * 2 + + result = asyncio.run(test_async_func(5)) + assert result == 10 class TestProfilerMethods: @@ -126,65 +159,67 @@ class TestProfilerMethods: @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_print_stats_when_disabled(self): + """Test print_stats does nothing when profiler is disabled.""" output = io.StringIO() print_stats(stream=output) assert output.getvalue() == "" @pytest.mark.skipif(is_enabled(), reason="Test only runs when profiler disabled") def test_get_stats_when_disabled(self): - assert get_stats() == "" + """Test get_stats returns empty string when profiler is disabled.""" + stats = get_stats() + assert stats == "" def test_pause_resume_methods(self): - # Must not raise, enabled or not. + """Test pause/resume methods don't crash when called.""" + # Should not raise any exceptions resume() pause() - def test_print_stats_no_output_when_no_functions(self, enabled_profiler): - if enabled_profiler.profiler is None: - pytest.skip("line_profiler not installed") - assert len(enabled_profiler.profiler.functions) == 0 - + def test_print_stats_with_prefix(self): + """Test print_stats with a prefix.""" output = io.StringIO() - enabled_profiler.print_stats(stream=output, prefix="Test") + print_stats(stream=output, prefix="Test Worker") + + # When disabled, should produce no output + if not is_enabled(): + assert output.getvalue() == "" + else: + # When enabled, should have the prefix in output + output_str = output.getvalue() + if output_str: # Only check if there's output + assert ( + "Test Worker - LINE PROFILER RESULTS" in output_str + or "Test Worker" in output_str + ) + + def test_print_stats_no_output_when_no_functions(self): + """Test print_stats produces no output when no functions have been profiled.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + line_profiler.ProfilerState._instance = None + state = line_profiler.ProfilerState() - assert output.getvalue() == "" + if state.profiler: + # Ensure no functions are profiled (fresh profiler) + assert len(state.profiler.functions) == 0 + + output = io.StringIO() + state.print_stats(stream=output, prefix="Test") + + # Should produce no output when no functions are profiled + assert output.getvalue() == "" class TestProfilerCleanup: - """Shutdown must always tear the C profiler down, exactly once.""" - - def test_shutdown_handles_multiple_calls(self, enabled_profiler): - enabled_profiler.shutdown() - enabled_profiler.shutdown() - enabled_profiler.shutdown() - assert enabled_profiler.profiler is None - - def test_shutdown_after_print_stats_still_tears_down(self, enabled_profiler): - """print_stats() marks stats printed; shutdown() must still teardown.""" - - @enabled_profiler.profile - def traced(x): - return x + 1 - - traced(1) - enabled_profiler.print_stats(stream=io.StringIO()) - assert enabled_profiler._stats_printed is True - - enabled_profiler.shutdown() - assert enabled_profiler.profiler is None - - def test_shutdown_tears_down_when_output_destination_fails(self, enabled_profiler): - """A failing stats dump must still leave the C profiler disabled.""" - - @enabled_profiler.profile - def traced(x): - return x + 1 - - traced(1) - with mock.patch.object( - enabled_profiler, - "_print_stats_to_destination", - side_effect=OSError("disk full"), - ): - enabled_profiler.shutdown() - assert enabled_profiler.profiler is None + """Test profiler cleanup behavior.""" + + def test_shutdown_handles_multiple_calls(self): + """Test that shutdown can be called multiple times safely.""" + with mock.patch.dict(os.environ, {ENV_VAR_ENABLE_LINE_PROFILER: "1"}): + line_profiler.ProfilerState._instance = None + state = line_profiler.ProfilerState() + + # Should not raise an exception when called multiple times + state.shutdown() + state.shutdown() + state.shutdown() From a9a773d7acf6a3d1f968c3c4aeb225ff7c9f0e83 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 16:09:14 -0700 Subject: [PATCH 34/45] fix(interrupt): honest artifacts for grace-killed drains; lossless SIGINT restore Two holes found by post-simplification review: 1. Grace expiry SIGKILLs a wedged aggregator, so the report is built from the subscriber's last LIVE pub/sub snapshot - and the split-brain guard only rewrote state=="complete", letting an aborted run persist result_summary.json with state:"live". The guard now rewrites any aborted non-interrupted state; the wedge integration test asserts the summary lands interrupted/complete:false and the unit guard test is parametrized over both snapshot states. 2. signal.signal(SIGINT, None) raises TypeError, so the sentinel-based restore was broken for exactly the case it existed for (a C-installed previous handler, getsignal()->None). Restoring SIG_DFL instead would destroy the host's handler, so the governor now probes getsignal() first and refuses to install over an unrepresentable C handler (stays passive, same stance as off-main-thread); the restore path only ever sees Python-representable handlers. The _SIGINT_NOT_INSTALLED sentinel, its object-typed local, and the type:ignore are gone (both run_benchmark and run_audit). --- src/inference_endpoint/commands/audit.py | 21 +++++--- .../commands/benchmark/execute.py | 51 +++++++++---------- tests/integration/commands/test_sigint.py | 8 +++ tests/unit/commands/test_benchmark.py | 26 +++++----- 4 files changed, 60 insertions(+), 46 deletions(-) diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index bf07a55a9..a92f084c0 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -38,7 +38,6 @@ from ..config.schema import AuditConfig, BenchmarkConfig, DatasetType from ..exceptions import ExecutionError, SetupError from .benchmark.execute import ( - _SIGINT_NOT_INSTALLED, BenchmarkResult, TestMode, _salvage_tmpfs, @@ -86,16 +85,22 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: # The flag persisting across phases is moot: an interrupted phase raises # before the next phase starts. sigint = SigintGovernor() - prev_sigint: object = _SIGINT_NOT_INSTALLED - try: - prev_sigint = signal.signal(signal.SIGINT, sigint) - except ValueError: - pass # not the main thread (embedded use): governor stays passive + sigint_installed = False + # getsignal returns None for a C-installed handler Python cannot represent + # (and signal.signal would refuse to accept back): leave it untouched and + # keep the governor passive, exactly as off the main thread. + prev_sigint = signal.getsignal(signal.SIGINT) + if prev_sigint is not None: + try: + signal.signal(signal.SIGINT, sigint) + sigint_installed = True + except ValueError: + pass # not the main thread (embedded use): governor stays passive try: artifacts = _run_phases(config, base_report_dir, test, audit_cfg, specs, sigint) finally: - if prev_sigint is not _SIGINT_NOT_INSTALLED: - signal.signal(signal.SIGINT, prev_sigint) # type: ignore[arg-type] + if sigint_installed: + signal.signal(signal.SIGINT, prev_sigint) # Normalizes verify()'s zero-QPS ValueError to exit 4, not a traceback. try: diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index b1d0e6dd2..b79de8c2d 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -1037,12 +1037,6 @@ def _on_phase_start(phase: PhaseConfig) -> None: ) -_SIGINT_NOT_INSTALLED = object() -"""Sentinel separating "governor never installed" from a ``None`` previous -handler (``signal.signal`` returns ``None`` for C-installed handlers, which -must still be restored).""" - - def _run_deadline(config: BenchmarkConfig) -> float | None: """Monotonic deadline for ``settings.timeouts.run_timeout_s`` (None = off). @@ -1194,17 +1188,18 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: collector = bench.collector report = bench.report aborted = bench.run_timed_out or bench.user_interrupted - if report is not None and aborted and report.state == "complete": - # Split-brain guard: the aggregator may have finalized COMPLETE before - # the watchdog's SIGTERM landed — or a ^C arrived after the session - # already published its terminal ENDED (drain window), so the - # INTERRUPTED marker never went out. Keyed on state (not the derived - # ``complete`` flag) so the drain-timeout subcase — state "complete" - # with pending tasks — is corrected too. An aborted run must never - # publish state-complete artifacts, so force both fields honest before - # writing; consumers keying on state=="complete" and not complete (the - # drain-timeout signature) then can't misattribute an abort to a slow - # drain. + if report is not None and aborted and report.state != "interrupted": + # Split-brain guard: an aborted run must never publish artifacts under + # any other state. "complete": the aggregator finalized before the + # watchdog's SIGTERM landed, or a ^C arrived after the session already + # published its terminal ENDED (drain window), so the INTERRUPTED + # marker never went out. "live": the teardown grace SIGKILLed a wedged + # aggregator, so the report was built from the subscriber's last live + # snapshot. Keyed on state (not the derived ``complete`` flag) so the + # drain-timeout subcase — state "complete" with pending tasks — is + # corrected too. Force both fields honest before writing; consumers + # keying on state=="complete" and not complete (the drain-timeout + # signature) then can't misattribute an abort to a slow drain. report = msgspec.structs.replace(report, complete=False, state="interrupted") # Write scoring artifacts + copy event log from tmpfs to disk (scorers read @@ -1304,11 +1299,17 @@ def run_benchmark( # there is no gap where a ^C aborts teardown as a raw KeyboardInterrupt. # No session bound yet, so a ^C during setup keeps default abort behavior. sigint = SigintGovernor() - prev_sigint: object = _SIGINT_NOT_INSTALLED - try: - prev_sigint = signal.signal(signal.SIGINT, sigint) - except ValueError: - pass # not the main thread (embedded use): governor stays passive + sigint_installed = False + # getsignal returns None for a C-installed handler Python cannot represent + # (and signal.signal would refuse to accept back): leave it untouched and + # keep the governor passive, exactly as off the main thread. + prev_sigint = signal.getsignal(signal.SIGINT) + if prev_sigint is not None: + try: + signal.signal(signal.SIGINT, sigint) + sigint_installed = True + except ValueError: + pass # not the main thread (embedded use): governor stays passive bench: BenchmarkResult | None = None try: ctx = setup_benchmark(config, test_mode) @@ -1374,10 +1375,8 @@ def run_benchmark( logger.warning("Benchmark interrupted by user") raise finally: - if prev_sigint is not _SIGINT_NOT_INSTALLED: - # Restore whatever was installed before — including None (a - # C-installed handler), which `signal.signal` accepts back. - signal.signal(signal.SIGINT, prev_sigint) # type: ignore[arg-type] + if sigint_installed: + signal.signal(signal.SIGINT, prev_sigint) if bench: if bench.tmpfs_dir.exists(): try: diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 7bfc3105d..380f151f6 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -264,6 +264,14 @@ def test_sigint_grace_expiry_abandons_wedged_drain(mock_http_echo_server, tmp_pa assert ( abort_latency < 20.0 ), f"abort took {abort_latency:.1f}s — grace escalation did not fire" + # The SIGKILLed aggregator never wrote a terminal snapshot, so the report + # was built from the last live pub/sub frame — the split-brain guard must + # still land the summary as interrupted, never state:"live". + summary = json.loads( + (report_dir / "performance" / "result_summary.json").read_text() + ) + assert summary["state"] == "interrupted" + assert summary["complete"] is False _assert_no_leftover_children(report_dir, "grace-expired abort") diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index 53389676a..d009b9225 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -2254,18 +2254,20 @@ def test_skip_endpoint_phase_scorer_reports_external_sample_count( @pytest.mark.unit @pytest.mark.parametrize("abort_field", ["run_timed_out", "user_interrupted"]) - def test_aborted_run_never_writes_complete_artifacts(self, tmp_path, abort_field): - """Split-brain guard: the aggregator finalized a COMPLETE snapshot but - the run was aborted — watchdog fired late, or a ^C landed after the - session's terminal ENDED (drain window), so the INTERRUPTED marker - never went out. result_summary.json must still land complete:false / - state:interrupted; an aborted run must never ship complete artifacts.""" + @pytest.mark.parametrize("snapshot_state", ["complete", "live"]) + def test_aborted_run_never_writes_complete_artifacts( + self, tmp_path, abort_field, snapshot_state + ): + """Split-brain guard: an aborted run must never ship artifacts under + any non-interrupted state. "complete": the aggregator finalized before + the abort landed (watchdog late, or ^C after the session's terminal + ENDED). "live": the teardown grace SIGKILLed a wedged aggregator, so + the report was built from the last live pub/sub snapshot. + result_summary.json must land complete:false / state:interrupted.""" config = OfflineConfig(**_OFFLINE_KWARGS) ctx = _make_benchmark_context(config=config, report_dir=tmp_path) - report = self._make_complete_report() - assert report.complete is True, "precondition: aggregator said COMPLETE" bench = _make_benchmark_result(tmp_path) - bench.report = report + bench.report = self._make_report(state=snapshot_state) setattr(bench, abort_field, True) finalize_benchmark(ctx, bench) @@ -2291,7 +2293,7 @@ def test_sigint_during_scoring_invalidates_run(self, tmp_path, monkeypatch): config = OfflineConfig(**_OFFLINE_KWARGS) ctx = _make_benchmark_context(config=config, report_dir=tmp_path) bench = _make_benchmark_result(tmp_path) - bench.report = self._make_complete_report() + bench.report = self._make_report(state="complete") monkeypatch.setattr( execute_mod, "score_accuracy", @@ -2308,12 +2310,12 @@ def test_sigint_during_scoring_invalidates_run(self, tmp_path, monkeypatch): assert summary["state"] == "interrupted" @staticmethod - def _make_complete_report() -> Report: + def _make_report(state: str) -> Report: return Report.from_snapshot( { "counter": 1, "timestamp_ns": 12345, - "state": "complete", + "state": state, "n_pending_tasks": 0, "metrics": [ { From 6105b8858f3181718ded2c03cc97bbc735b5547a Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 16:45:56 -0700 Subject: [PATCH 35/45] =?UTF-8?q?fix(interrupt):=20quality-council=20round?= =?UTF-8?q?=20=E2=80=94=20audit=20honesty,=20exit-code=20precedence,=20sha?= =?UTF-8?q?rd=20reaping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Council findings (two parallel reviewers, addressed locally): Correctness: - finalize_benchmark writes the split-brain-rewritten report back onto BenchmarkResult, so the audit runner's post-phase state check sees the honest interrupted state instead of the aggregator's stale COMPLETE (a drain-window ^C during the final audit phase could previously certify a PASS). - run_benchmark refreshes user_interrupted from the governor after the loop returns: a ^C landing between the coroutine's flag snapshot and task completion can no longer produce COMPLETE artifacts on an exit-130 run. - A drain/report failure on an interrupted run no longer re-raises as ExecutionError (exit 4): the user's abort outranks a drain error its own grace escalation may have caused - exit stays 130. - The finalization KeyboardInterrupt handler now covers every post-measurement write (scoring, summary log, profiling.json, accuracy_results), rewriting and RE-persisting the summary as interrupted if it was already written COMPLETE. - Tokenizer shard workers arm PR_SET_PDEATHSIG=SIGKILL in their initializer: a SIGKILLed aggregator (watchdog / teardown-grace escalation) can no longer orphan the non-daemon ProcessPool shards that are outside every launcher PID list. Behavioral test reads PR_GET_PDEATHSIG back from a real pool worker. - SIGINT install/restore is one shared context manager (watchdog.sigint_policy) used by run_benchmark and run_audit: probes getsignal() first (an unrepresentable C handler stays untouched, governor passive), restores after the caller's finally so a repeat ^C during tmpfs salvage still hits the governor's no-op. Quality: _on_global_timeout renamed _on_perf_phase_timeout (it only caps the perf phase), Timeouts docstring no longer promises None for service_ready_timeout_s, sigint test helpers deduplicate the /proc scan and name timeout_s, grace-expiry unit test asserts exactly one fire, timeouts validation tests pin the failing field (extra=forbid would mask typos), dict annotations typed. --- .../metrics_aggregator/token_metrics.py | 19 ++ src/inference_endpoint/commands/audit.py | 19 +- .../commands/benchmark/execute.py | 303 +++++++++--------- .../commands/benchmark/watchdog.py | 48 ++- src/inference_endpoint/config/schema.py | 5 +- .../integration/commands/test_run_timeout.py | 7 +- tests/integration/commands/test_sigint.py | 40 +-- .../metrics_aggregator/test_token_metrics.py | 24 ++ tests/unit/commands/test_watchdog.py | 36 ++- tests/unit/config/test_timeouts.py | 4 +- 10 files changed, 315 insertions(+), 190 deletions(-) diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py index ffbd7dbeb..b542c55ab 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py @@ -31,6 +31,7 @@ from __future__ import annotations import asyncio +import ctypes import json import logging import multiprocessing @@ -156,6 +157,18 @@ def load_reference_backend(tokenizer_name: str) -> Any | None: return getattr(tokenizer, "backend_tokenizer", None) +def _install_parent_death_signal() -> None: + """Linux prctl(PR_SET_PDEATHSIG, SIGKILL); silent no-op elsewhere.""" + try: + libc = ctypes.CDLL(None, use_errno=True) + PR_SET_PDEATHSIG = 1 + libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0) + except (OSError, AttributeError): + # Non-Linux: no prctl. The worker then relies on the executor's + # normal shutdown path. + logger.debug("could not arm parent-death signal for tokenizer worker") + + def _init_worker(tokenizer_name: str, core_set: list[int]) -> None: """Pin this worker to ``core_set``, then load its token-counting path. @@ -167,6 +180,12 @@ def _init_worker(tokenizer_name: str, core_set: list[int]) -> None: # drives worker shutdown, so a worker dying mid-drain would break the pool # and lose the buffered tokenizations it was counting. signal.signal(signal.SIGINT, signal.SIG_IGN) + # If the aggregator is SIGKILLed (run-watchdog / ^C teardown-grace + # escalation), BatchTokenizer.close() never runs and these non-daemon + # workers are outside every launcher PID list — ask the kernel to SIGKILL + # this worker when its parent dies so no shard can outlive the run + # (PR_SET_PDEATHSIG; Linux-only, best-effort elsewhere). + _install_parent_death_signal() if core_set: # Size the Hugging Face rayon pool to the block explicitly: the parent # process caps its own pool for the live lane, and spawn children inherit diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index a92f084c0..86c849053 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -30,7 +30,6 @@ import logging import shutil -import signal from pathlib import Path from ..compliance import AuditRunArtifacts, AuditRunSpec, AuditTest, get_audit_test @@ -45,7 +44,7 @@ run_benchmark_async, setup_benchmark, ) -from .benchmark.watchdog import SigintGovernor +from .benchmark.watchdog import SigintGovernor, sigint_policy logger = logging.getLogger(__name__) @@ -85,22 +84,8 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: # The flag persisting across phases is moot: an interrupted phase raises # before the next phase starts. sigint = SigintGovernor() - sigint_installed = False - # getsignal returns None for a C-installed handler Python cannot represent - # (and signal.signal would refuse to accept back): leave it untouched and - # keep the governor passive, exactly as off the main thread. - prev_sigint = signal.getsignal(signal.SIGINT) - if prev_sigint is not None: - try: - signal.signal(signal.SIGINT, sigint) - sigint_installed = True - except ValueError: - pass # not the main thread (embedded use): governor stays passive - try: + with sigint_policy(sigint): artifacts = _run_phases(config, base_report_dir, test, audit_cfg, specs, sigint) - finally: - if sigint_installed: - signal.signal(signal.SIGINT, prev_sigint) # Normalizes verify()'s zero-QPS ValueError to exit 4, not a traceback. try: diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index b79de8c2d..5befc3238 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -32,7 +32,6 @@ import logging import random import shutil -import signal import tempfile import time import uuid @@ -66,6 +65,7 @@ PerfPhaseTimeout, RunWatchdog, SigintGovernor, + sigint_policy, ) from inference_endpoint.compliance import AuditRunSpec from inference_endpoint.config.runtime_settings import RuntimeSettings @@ -874,11 +874,11 @@ async def _run_benchmark_async( if ctx.rt_settings is not None else None ) - _timeout_done = False + _perf_cap_done = False session_completed_normally = False - def _on_global_timeout() -> None: - if not _timeout_done: + def _on_perf_phase_timeout() -> None: + if not _perf_cap_done: logger.warning( "Performance phase max_duration reached (%d ms); " "ending performance phase.", @@ -890,7 +890,7 @@ def _on_global_timeout() -> None: session.stop_current_phase() perf_timeout = PerfPhaseTimeout( - loop, max_duration_ms, _on_global_timeout + loop, max_duration_ms, _on_perf_phase_timeout ) def _on_phase_start(phase: PhaseConfig) -> None: @@ -941,7 +941,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: else: raise ExecutionError(f"Benchmark execution failed: {e}") from e finally: - _timeout_done = True + _perf_cap_done = True perf_timeout.cancel() # NOTE: no SIGINT bookkeeping here — the process-level # SigintGovernor (installed once by run_benchmark) covers @@ -970,8 +970,12 @@ def _on_phase_start(phase: PhaseConfig) -> None: # path the run is already raising, so swallow it # there rather than let a teardown error replace the # in-flight exception; run_benchmark still fails the - # run on a missing report. - if session_completed_normally: + # run on a missing report. A ^C'd run swallows it too: + # the user's abort (exit 130) outranks a drain error + # its own grace escalation may have caused. + if session_completed_normally and not ( + sigint is not None and sigint.interrupted + ): raise logger.warning( "Drain/report build error suppressed (run " @@ -1201,6 +1205,10 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # keying on state=="complete" and not complete (the drain-timeout # signature) then can't misattribute an abort to a slow drain. report = msgspec.structs.replace(report, complete=False, state="interrupted") + # Write back so callers holding the BenchmarkResult (the audit runner + # checks bench.report.state after each phase) see the honest state, + # not the aggregator's stale COMPLETE. + bench.report = report # Write scoring artifacts + copy event log from tmpfs to disk (scorers read # sample_idx_map.json + events.jsonl from here). @@ -1211,57 +1219,68 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # is written in the `finally` below so a scoring failure (e.g. lcb-service # unreachable, missing eval subproject, bad extras) still leaves the perf # run's result_summary.json / report.txt on disk instead of discarding them — - # then the exception propagates as before. A ^C landing here (the governor - # raises KeyboardInterrupt mid-scoring once the run task is done) is - # different: a user abort makes the whole run invalid, so the report is - # rewritten interrupted/complete:false before the `finally` persists it — - # the metrics stay in the file as partial diagnostics — and the interrupt - # propagates for exit 130. + # then the exception propagates as before. A ^C landing anywhere in + # finalization (the governor raises KeyboardInterrupt once the run task is + # done) is different: a user abort makes the whole run invalid, so the + # report is rewritten interrupted/complete:false and re-persisted by the + # outer handler below — the metrics stay in the file as partial + # diagnostics — and the interrupt propagates for exit 130. accuracy_scores: list[dict[str, Any]] = [] try: - if aborted: - # Phases may never have started (scorer init KeyErrors on missing - # sample maps) and partial phases would yield misleading subset - # scores; the scoring artifacts above are still on disk for - # inspection. - logger.warning( - "Run aborted (%s) — skipping accuracy scoring on partial data", - "run timeout" if bench.run_timed_out else "user interrupt", - ) - else: - accuracy_scores = score_accuracy(ctx, result) + try: + if aborted: + # Phases may never have started (scorer init KeyErrors on + # missing sample maps) and partial phases would yield + # misleading subset scores; the scoring artifacts above are + # still on disk for inspection. + logger.warning( + "Run aborted (%s) — skipping accuracy scoring on partial data", + "run timeout" if bench.run_timed_out else "user interrupt", + ) + else: + accuracy_scores = score_accuracy(ctx, result) + finally: + # Attach the per-dataset accuracy list so result_summary.json, + # the console summary, and report.txt all carry it (stays [] on + # a scoring failure). + if report is not None: + report = msgspec.structs.replace(report, accuracy=accuracy_scores) + # Display the report + write result_summary.json / report.txt. + if report is not None: + _write_report_artifacts(ctx, report, bench.profiling) + + _summarize_and_log_metrics(ctx, report, result, collector) + + # Sibling profiling.json — kept separate so Report stays a pure + # snapshot-derived struct. Written after the report artifacts (and + # best-effort) so an OSError here can't discard the already-written + # perf report. + if bench.profiling is not None: + try: + (ctx.report_dir / "profiling.json").write_text( + json.dumps(bench.profiling, indent=2) + ) + except OSError as e: + logger.warning("Failed to write profiling.json: %s", e) + + # Emit the accuracy results as a focused artifact under accuracy/. + # Written after the report artifacts so a write failure here can't + # discard them. + write_accuracy_results(ctx.report_dir, accuracy_scores) except KeyboardInterrupt: - if report is not None: - report = msgspec.structs.replace( + # ^C anywhere in finalization: the run is invalid. Rewrite and + # re-persist the summary (overwriting a COMPLETE one the finally may + # already have written), then propagate for exit 130. + if report is not None and report.state != "interrupted": + invalidated = msgspec.structs.replace( report, complete=False, state="interrupted" ) + _write_report_artifacts(ctx, invalidated, bench.profiling) + report = invalidated + bench.report = report raise - finally: - # Attach the per-dataset accuracy list so result_summary.json, the - # console summary, and report.txt all carry it (stays [] on a scoring - # failure). - if report is not None: - report = msgspec.structs.replace(report, accuracy=accuracy_scores) - # Display the report + write result_summary.json / report.txt. - if report is not None: - _write_report_artifacts(ctx, report, bench.profiling) - - _summarize_and_log_metrics(ctx, report, result, collector) - - # Sibling profiling.json — kept separate so Report stays a pure snapshot- - # derived struct. Written after the report artifacts (and best-effort) so an - # OSError here can't discard the already-written perf report. - if bench.profiling is not None: - try: - (ctx.report_dir / "profiling.json").write_text( - json.dumps(bench.profiling, indent=2) - ) - except OSError as e: - logger.warning("Failed to write profiling.json: %s", e) - # Emit the accuracy results as a focused artifact under accuracy/. Written - # after the report artifacts so a write failure here can't discard them. - write_accuracy_results(ctx.report_dir, accuracy_scores) + bench.report = report def run_benchmark( @@ -1294,100 +1313,94 @@ def run_benchmark( # (tokenizer/dataset load) counts against run_timeout_s too. deadline = _run_deadline(config) run_timeout_s = config.settings.timeouts.run_timeout_s - # The run's ONE SIGINT handler, installed here and restored in the finally - # — no window-scoped install/remove pairs anywhere else in the run, so - # there is no gap where a ^C aborts teardown as a raw KeyboardInterrupt. - # No session bound yet, so a ^C during setup keeps default abort behavior. + # The run's ONE SIGINT handler — no window-scoped install/remove pairs + # anywhere else in the run, so there is no gap where a ^C aborts teardown + # as a raw KeyboardInterrupt. No session bound yet, so a ^C during setup + # keeps default abort behavior. sigint_policy restores the previous + # handler only after the finally below, so a repeat ^C during salvage + # still hits the governor's no-op. sigint = SigintGovernor() - sigint_installed = False - # getsignal returns None for a C-installed handler Python cannot represent - # (and signal.signal would refuse to accept back): leave it untouched and - # keep the governor passive, exactly as off the main thread. - prev_sigint = signal.getsignal(signal.SIGINT) - if prev_sigint is not None: - try: - signal.signal(signal.SIGINT, sigint) - sigint_installed = True - except ValueError: - pass # not the main thread (embedded use): governor stays passive bench: BenchmarkResult | None = None - try: - ctx = setup_benchmark(config, test_mode) - if deadline is not None and time.monotonic() >= deadline: - # Setup alone consumed the budget: fail before any services start. - raise ExecutionError( - f"Run timeout ({run_timeout_s}s) reached during setup; " - "no services were started" - ) - bench = run_benchmark_async(ctx, deadline=deadline, sigint=sigint) - finalize_benchmark(ctx, bench) - if bench.user_interrupted or sigint.interrupted: - # Artifacts are finalized (state=interrupted, complete:false) - # ABOVE — only now surface the ^C so main.py exits 130. Checked - # before run_timed_out: if the user interrupted a run whose - # watchdog also fired, the user's abort is the truthful cause. - raise KeyboardInterrupt - if bench.run_timed_out: - raise ExecutionError( - f"Run timeout ({run_timeout_s}s) reached; run aborted and " - "report marked INTERRUPTED" - ) - if ( - bench.report is not None - and bench.report.state == "interrupted" - and not bench.run_timed_out - ): - # The session was stopped without a user ^C or a watchdog fire: - # transport closure or an external stop. Never exit 0 on - # interrupted artifacts. - raise ExecutionError( - "Session aborted before completion (transport closure or " - "external stop); report marked INTERRUPTED" - ) - if ( - bench.report is not None - and bench.report.state == "complete" - and not bench.report.complete - ): - # The aggregator gave up on its tokenization backlog when - # metrics_drain_timeout_s expired (state "complete" with pending - # tasks). The artifacts above are already written with - # complete: false; fail loudly instead of exiting 0 on partial - # ISL/OSL/TPOT stats. - raise ExecutionError( - "Metrics tokenization did not finish (n_pending_tasks > 0 in " - "the final snapshot): the drain deadline expired " - f"(metrics_drain_timeout_s=" - f"{config.settings.timeouts.metrics_drain_timeout_s}) or the " - "tokenizer failed mid-drain — see the aggregator log; report " - "is partial (complete: false in result_summary.json)" - ) - if bench.report is None: - # Aborted-without-flags path (e.g. transport closure) whose drain - # also failed: nothing above raised, but there is no report to - # stand behind — never exit 0 without one. - raise ExecutionError( - "Benchmark produced no usable metrics report; see the drain " - "errors above" - ) - except KeyboardInterrupt: - # Salvage results (finally), then propagate to main.py -> exit 130. - logger.warning("Benchmark interrupted by user") - raise - finally: - if sigint_installed: - signal.signal(signal.SIGINT, prev_sigint) - if bench: - if bench.tmpfs_dir.exists(): - try: - _salvage_tmpfs(ctx.report_dir, bench.tmpfs_dir) - shutil.rmtree(bench.tmpfs_dir, ignore_errors=True) - except Exception as e: # noqa: BLE001 — salvage best-effort - logger.warning( - "Failed to salvage tmpfs: %s — tmpfs retained at %s", - e, - bench.tmpfs_dir, - ) - logger.info(f"Partial results saved to {ctx.report_dir}") + with sigint_policy(sigint): + try: + ctx = setup_benchmark(config, test_mode) + if deadline is not None and time.monotonic() >= deadline: + # Setup alone consumed the budget: fail before services start. + raise ExecutionError( + f"Run timeout ({run_timeout_s}s) reached during setup; " + "no services were started" + ) + bench = run_benchmark_async(ctx, deadline=deadline, sigint=sigint) + # A ^C can land between the coroutine's own flag snapshot and the + # loop returning — refresh from the governor so finalization never + # writes COMPLETE artifacts for a run that exits 130. + bench.user_interrupted = bench.user_interrupted or sigint.interrupted + finalize_benchmark(ctx, bench) + if bench.user_interrupted or sigint.interrupted: + # Artifacts are finalized (state=interrupted, complete:false) + # ABOVE — only now surface the ^C so main.py exits 130. Checked + # before run_timed_out: if the user interrupted a run whose + # watchdog also fired, the user's abort is the truthful cause. + raise KeyboardInterrupt + if bench.run_timed_out: + raise ExecutionError( + f"Run timeout ({run_timeout_s}s) reached; run aborted and " + "report marked INTERRUPTED" + ) + if ( + bench.report is not None + and bench.report.state == "interrupted" + and not bench.run_timed_out + ): + # The session was stopped without a user ^C or a watchdog + # fire: transport closure or an external stop. Never exit 0 + # on interrupted artifacts. + raise ExecutionError( + "Session aborted before completion (transport closure or " + "external stop); report marked INTERRUPTED" + ) + if ( + bench.report is not None + and bench.report.state == "complete" + and not bench.report.complete + ): + # The aggregator gave up on its tokenization backlog when + # metrics_drain_timeout_s expired (state "complete" with + # pending tasks). The artifacts above are already written with + # complete: false; fail loudly instead of exiting 0 on partial + # ISL/OSL/TPOT stats. + raise ExecutionError( + "Metrics tokenization did not finish (n_pending_tasks > 0 " + "in the final snapshot): the drain deadline expired " + f"(metrics_drain_timeout_s=" + f"{config.settings.timeouts.metrics_drain_timeout_s}) or " + "the tokenizer failed mid-drain — see the aggregator log; " + "report is partial (complete: false in result_summary.json)" + ) + if bench.report is None: + # Aborted-without-flags path (e.g. transport closure) whose + # drain also failed: nothing above raised, but there is no + # report to stand behind — never exit 0 without one. + raise ExecutionError( + "Benchmark produced no usable metrics report; see the " + "drain errors above" + ) + except KeyboardInterrupt: + # Salvage results (finally), then propagate to main.py -> exit 130. + logger.warning("Benchmark interrupted by user") + raise + finally: + if bench: + if bench.tmpfs_dir.exists(): + try: + _salvage_tmpfs(ctx.report_dir, bench.tmpfs_dir) + shutil.rmtree(bench.tmpfs_dir, ignore_errors=True) + except Exception as e: # noqa: BLE001 — salvage best-effort + logger.warning( + "Failed to salvage tmpfs: %s — tmpfs retained at %s", + e, + bench.tmpfs_dir, + ) + logger.info(f"Partial results saved to {ctx.report_dir}") return ctx.report_dir diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index 760fec68f..0e22abe18 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -24,10 +24,12 @@ from __future__ import annotations import asyncio +import contextlib import logging +import signal import time import types -from collections.abc import Callable +from collections.abc import Callable, Iterator from typing import TYPE_CHECKING from inference_endpoint.load_generator.session import BenchmarkSession, PhaseType @@ -135,6 +137,33 @@ def __call__(self, signum: int, frame: types.FrameType | None) -> None: self._loop.call_soon_threadsafe(self._stop_gracefully) +@contextlib.contextmanager +def sigint_policy(governor: SigintGovernor) -> Iterator[None]: + """Install ``governor`` as the SIGINT handler; restore the previous one. + + Stays passive (installs nothing) when ``signal.getsignal`` returns + ``None`` — a C-installed handler Python cannot represent and + ``signal.signal`` would refuse to accept back — or when installation + raises ValueError (not the main thread; embedded use). Restoration + happens on exit, after the caller's own finally blocks, so a repeat ^C + during cleanup still hits the governor's no-op instead of the default + handler. + """ + prev = signal.getsignal(signal.SIGINT) + if prev is None: + yield + return + try: + signal.signal(signal.SIGINT, governor) + except ValueError: + yield + return + try: + yield + finally: + signal.signal(signal.SIGINT, prev) + + class PerfPhaseTimeout: """Session-stop timer that bounds the PERFORMANCE phase only. @@ -179,7 +208,10 @@ class RunWatchdog: the event logger — spared the SIGTERM — flushes and exits) and SIGTERM the aggregator, whose handler immediately writes the INTERRUPTED final snapshot with whatever stats it holds at that instant (``publish_final`` - is first-wins, so INTERRUPTED stays authoritative). Before the session + is first-wins, so INTERRUPTED stays authoritative). If the aggregator + ignores the SIGTERM (wedged/unschedulable), the same teardown grace as + the ^C path SIGTERM→SIGKILLs every service child so the drain's + wait-for-exit unblocks — the deadline is a hard bound. Before the session exists (service launch / endpoint connect still pending), stopping nothing would let those awaits run out their own readiness timeouts past the deadline — so the orchestration task is cancelled instead, which @@ -189,6 +221,8 @@ class RunWatchdog: a still-draining aggregator finalized COMPLETE first. """ + TEARDOWN_GRACE_S = SigintGovernor.TEARDOWN_GRACE_S + def __init__( self, loop: asyncio.AbstractEventLoop, @@ -199,6 +233,8 @@ def __init__( self._session: BenchmarkSession | None = None self._task: asyncio.Task | None = None self._pipe = pipe + self._loop = loop + self._escalation: asyncio.TimerHandle | None = None self._handle = ( loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) if deadline is not None @@ -238,8 +274,16 @@ def _fire(self) -> None: return self._session.stop() self._pipe.terminate_metrics_aggregator() + # A wedged aggregator ignores the SIGTERM; escalate so the deadline + # stays a hard bound (cancelled by cancel() when the drain finishes). + self._escalation = self._loop.call_later( + self.TEARDOWN_GRACE_S, self._pipe.abandon_drain + ) def cancel(self) -> None: if self._handle is not None: self._handle.cancel() self._handle = None + if self._escalation is not None: + self._escalation.cancel() + self._escalation = None diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 8335062f4..f6311d1c1 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -806,8 +806,9 @@ class Timeouts(WithUpdatesMixin, BaseModel): Two value conventions, stated once here: - - Wait bounds (``service_ready_timeout_s``, ``*_drain_timeout_s``): - ``None`` = wait indefinitely, ``0`` = zero budget (give up / skip + - Drain bounds (``*_drain_timeout_s``): ``None`` = wait indefinitely, + ``0`` = zero budget. ``service_ready_timeout_s`` is a finite + non-negative bound (never ``None``) (give up / skip immediately). - The watchdog (``run_timeout_s``): ``None`` = off; ``0`` is rejected (``gt=0``) because a zero-length run is never meaningful — there is no diff --git a/tests/integration/commands/test_run_timeout.py b/tests/integration/commands/test_run_timeout.py index c96134199..0efb6c63f 100644 --- a/tests/integration/commands/test_run_timeout.py +++ b/tests/integration/commands/test_run_timeout.py @@ -24,6 +24,7 @@ import json import time from pathlib import Path +from typing import Any import pytest from inference_endpoint.commands.benchmark.execute import ( @@ -58,13 +59,13 @@ _FAST_CLIENT = HTTPClientConfig(num_workers=1, warmup_connections=0, max_connections=10) -def _read_final_snapshot(report_dir: Path) -> dict: +def _read_final_snapshot(report_dir: Path) -> dict[str, Any]: snapshot_path = report_dir / "metrics" / "final_snapshot.json" assert snapshot_path.exists(), "aggregator must still write a final snapshot" return json.loads(snapshot_path.read_text()) -def _read_result_summary(report_dir: Path) -> dict: +def _read_result_summary(report_dir: Path) -> dict[str, Any]: return json.loads((report_dir / "performance" / "result_summary.json").read_text()) @@ -80,7 +81,7 @@ def _make_config( timeouts: Timeouts | None = None, metrics_tokenizer_workers: int | None = None, ) -> BenchmarkConfig: - settings_kwargs: dict = { + settings_kwargs: dict[str, Any] = { "load_pattern": load_pattern or LoadPattern(type=LoadPatternType.MAX_THROUGHPUT), "client": _FAST_CLIENT, diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 380f151f6..f2dc38b2a 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -122,31 +122,39 @@ def _benchmark_proc( def _wait_services_ready( - proc: subprocess.Popen, report_dir: Path, timeout: float = 60.0 + proc: subprocess.Popen, report_dir: Path, timeout_s: float = 60.0 ) -> None: """Block until the aggregator touches metrics/.ready (handlers installed); the session starts issuing right after service readiness.""" ready = report_dir / "metrics" / ".ready" - deadline = time.monotonic() + timeout + deadline = time.monotonic() + timeout_s while not ready.exists(): assert proc.poll() is None, "benchmark died before services came up" assert time.monotonic() < deadline, "services never became ready" time.sleep(0.1) -def _procs_referencing(needle: str) -> list[str]: - """Cmdlines of live processes whose argv mentions ``needle`` (Linux).""" - hits = [] +def _iter_proc_cmdlines() -> Iterator[tuple[int, bytes]]: + """(pid, cmdline) for every live process (Linux); racing exits skipped.""" for pid_dir in Path("/proc").iterdir(): if not pid_dir.name.isdigit(): continue try: - cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\0", b" ") + yield ( + int(pid_dir.name), + (pid_dir / "cmdline").read_bytes().replace(b"\0", b" "), + ) except OSError: continue # process exited mid-scan - if needle.encode() in cmdline: - hits.append(cmdline.decode(errors="replace")) - return hits + + +def _procs_referencing(needle: str) -> list[str]: + """Cmdlines of live processes whose argv mentions ``needle``.""" + return [ + cmdline.decode(errors="replace") + for _, cmdline in _iter_proc_cmdlines() + if needle.encode() in cmdline + ] def _assert_no_leftover_children(report_dir: Path, what: str) -> None: @@ -169,16 +177,10 @@ def _assert_interrupted_artifacts(report_dir: Path) -> None: def _pid_of_child(needle: str, extra: str) -> int | None: - """PID of a live process whose argv mentions both needles (Linux).""" - for pid_dir in Path("/proc").iterdir(): - if not pid_dir.name.isdigit(): - continue - try: - cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\0", b" ") - except OSError: - continue # process exited mid-scan + """PID of a live process whose argv mentions both needles.""" + for pid, cmdline in _iter_proc_cmdlines(): if needle.encode() in cmdline and extra.encode() in cmdline: - return int(pid_dir.name) + return pid return None @@ -325,7 +327,7 @@ def test_single_group_sigint_under_uv_run_is_graceful(mock_http_echo_server, tmp str(config_path), ] ) as proc: - _wait_services_ready(proc, report_dir, timeout=90.0) + _wait_services_ready(proc, report_dir, timeout_s=90.0) time.sleep(3.0) os.killpg(proc.pid, signal.SIGINT) # one keystroke: group + uv forward rc = proc.wait(timeout=60.0) diff --git a/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py b/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py index 530435ce0..a561bbb7c 100644 --- a/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py +++ b/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py @@ -16,7 +16,10 @@ """Tests for BatchTokenizer and TokenBatchQueue.""" import asyncio +import ctypes import multiprocessing +import signal +import sys import time from concurrent.futures import Future, ProcessPoolExecutor from concurrent.futures.process import BrokenProcessPool @@ -991,3 +994,24 @@ def test_terminate_procs_kills_running_workers(): ex.shutdown(wait=False, cancel_futures=True) if manager_thread is not None: manager_thread.join(5) + + +def _report_pdeathsig() -> int: + """Arm the guard in this (child) process and read PR_GET_PDEATHSIG back.""" + token_metrics_module._install_parent_death_signal() + sig = ctypes.c_int() + libc = ctypes.CDLL(None, use_errno=True) + PR_GET_PDEATHSIG = 2 + libc.prctl(PR_GET_PDEATHSIG, ctypes.byref(sig), 0, 0, 0) + return sig.value + + +@pytest.mark.unit +def test_worker_parent_death_signal_is_sigkill(): + """A tokenizer shard must die with its parent (aggregator SIGKILLed by + the run-watchdog / ^C teardown-grace escalation): the initializer arms + PR_SET_PDEATHSIG=SIGKILL so no shard can outlive the run.""" + if not sys.platform.startswith("linux"): + pytest.skip("PR_SET_PDEATHSIG is Linux-only") + with ProcessPoolExecutor(max_workers=1) as ex: + assert ex.submit(_report_pdeathsig).result(timeout=30) == signal.SIGKILL diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index 63eaee85e..023a3343e 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -25,6 +25,7 @@ from inference_endpoint.commands.benchmark.watchdog import ( PerfPhaseTimeout, SigintGovernor, + sigint_policy, ) from inference_endpoint.load_generator.session import PhaseType @@ -95,15 +96,21 @@ async def test_repeat_sigint_is_a_noop(self): @pytest.mark.asyncio async def test_grace_expiry_fires_callback_once(self): + """The grace fires exactly once, even after repeat ^C deliveries.""" gov = SigintGovernor() gov.TEARDOWN_GRACE_S = 0.02 # instance override; class default untouched session = MagicMock() fired = asyncio.Event() + on_grace = MagicMock(side_effect=fired.set) gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) - gov.bind_session(session, fired.set) + gov.bind_session(session, on_grace) _fire(gov) + _fire(gov) # repeat ^C must not arm a second timer await asyncio.wait_for(fired.wait(), timeout=2.0) + await asyncio.sleep(0.05) # room for an (incorrect) second fire + + assert on_grace.call_count == 1 @pytest.mark.asyncio async def test_cancel_grace_disarms_pending_timer(self): @@ -122,6 +129,33 @@ async def test_cancel_grace_disarms_pending_timer(self): on_grace.assert_not_called() +@pytest.mark.unit +class TestSigintPolicy: + def test_installs_and_restores_previous_handler(self): + gov = SigintGovernor() + prev = signal.getsignal(signal.SIGINT) + with sigint_policy(gov): + assert signal.getsignal(signal.SIGINT) is gov + assert signal.getsignal(signal.SIGINT) is prev + + def test_restores_on_exception(self): + gov = SigintGovernor() + prev = signal.getsignal(signal.SIGINT) + with pytest.raises(RuntimeError), sigint_policy(gov): + raise RuntimeError("boom") + assert signal.getsignal(signal.SIGINT) is prev + + def test_unrepresentable_c_handler_stays_untouched(self, monkeypatch): + """getsignal()->None (C-installed handler): install nothing at all.""" + gov = SigintGovernor() + monkeypatch.setattr(signal, "getsignal", lambda signum: None) + install_spy = MagicMock() + monkeypatch.setattr(signal, "signal", install_spy) + with sigint_policy(gov): + pass + install_spy.assert_not_called() + + @pytest.mark.unit class TestPerfPhaseTimeout: """The max_duration_ms cap bounds only the performance phase and never diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index ca155d8bd..36080491f 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -81,7 +81,9 @@ class TestTimeoutsValidation: ) def test_deadline_must_be_positive_or_none(self, field, value): # The 0-sentinel is dead: unlimited is spelled None, never 0. - with pytest.raises(ValidationError): + # match=field: extra=forbid would also raise on a typo'd field name, + # so pin the error to the intended field. + with pytest.raises(ValidationError, match=field): Timeouts(**{field: value}) @pytest.mark.unit From 61900117b11a46baa3c6015a248a326d415308b7 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 16:49:55 -0700 Subject: [PATCH 36/45] chore(tests): nest pytest.raises around sigint_policy (CodeQL unreachable-code false positive) --- tests/unit/commands/test_watchdog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index 023a3343e..15b6d267e 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -141,8 +141,9 @@ def test_installs_and_restores_previous_handler(self): def test_restores_on_exception(self): gov = SigintGovernor() prev = signal.getsignal(signal.SIGINT) - with pytest.raises(RuntimeError), sigint_policy(gov): - raise RuntimeError("boom") + with pytest.raises(RuntimeError): + with sigint_policy(gov): + raise RuntimeError("boom") assert signal.getsignal(signal.SIGINT) is prev def test_unrepresentable_c_handler_stays_untouched(self, monkeypatch): From 0c65a0e6a74a7dfdbab63348d5d31f3889a985cf Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 17:00:17 -0700 Subject: [PATCH 37/45] refactor(interrupt): drop speculative hardening Cut the whole-finalize KeyboardInterrupt re-persist wrapper (guarded a millisecond window between artifact writes; the exit code is the documented run-outcome verdict) and the tokenizer-shard pdeathsig guard (pre-existing risk - main's terminate_all already SIGKILLs - and CF workers self-reap on queue EOF; follow-up material). The scoring-window invalidation, split-brain write-back, user_interrupted refresh, and exit-130 precedence stay: each enforces the documented contract in 1-3 lines. --- .../metrics_aggregator/token_metrics.py | 19 --- .../commands/benchmark/execute.py | 113 ++++++++---------- .../metrics_aggregator/test_token_metrics.py | 24 ---- 3 files changed, 52 insertions(+), 104 deletions(-) diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py index b542c55ab..ffbd7dbeb 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/token_metrics.py @@ -31,7 +31,6 @@ from __future__ import annotations import asyncio -import ctypes import json import logging import multiprocessing @@ -157,18 +156,6 @@ def load_reference_backend(tokenizer_name: str) -> Any | None: return getattr(tokenizer, "backend_tokenizer", None) -def _install_parent_death_signal() -> None: - """Linux prctl(PR_SET_PDEATHSIG, SIGKILL); silent no-op elsewhere.""" - try: - libc = ctypes.CDLL(None, use_errno=True) - PR_SET_PDEATHSIG = 1 - libc.prctl(PR_SET_PDEATHSIG, signal.SIGKILL, 0, 0, 0) - except (OSError, AttributeError): - # Non-Linux: no prctl. The worker then relies on the executor's - # normal shutdown path. - logger.debug("could not arm parent-death signal for tokenizer worker") - - def _init_worker(tokenizer_name: str, core_set: list[int]) -> None: """Pin this worker to ``core_set``, then load its token-counting path. @@ -180,12 +167,6 @@ def _init_worker(tokenizer_name: str, core_set: list[int]) -> None: # drives worker shutdown, so a worker dying mid-drain would break the pool # and lose the buffered tokenizations it was counting. signal.signal(signal.SIGINT, signal.SIG_IGN) - # If the aggregator is SIGKILLed (run-watchdog / ^C teardown-grace - # escalation), BatchTokenizer.close() never runs and these non-daemon - # workers are outside every launcher PID list — ask the kernel to SIGKILL - # this worker when its parent dies so no shard can outlive the run - # (PR_SET_PDEATHSIG; Linux-only, best-effort elsewhere). - _install_parent_death_signal() if core_set: # Size the Hugging Face rayon pool to the block explicitly: the parent # process caps its own pool for the live lane, and spawn children inherit diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 5befc3238..ab6b6885b 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -970,9 +970,9 @@ def _on_phase_start(phase: PhaseConfig) -> None: # path the run is already raising, so swallow it # there rather than let a teardown error replace the # in-flight exception; run_benchmark still fails the - # run on a missing report. A ^C'd run swallows it too: - # the user's abort (exit 130) outranks a drain error - # its own grace escalation may have caused. + # run on a missing report. A ^C'd run swallows it + # too: the user's abort (exit 130) outranks a drain + # error its own grace escalation may have caused. if session_completed_normally and not ( sigint is not None and sigint.interrupted ): @@ -1219,69 +1219,60 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: # is written in the `finally` below so a scoring failure (e.g. lcb-service # unreachable, missing eval subproject, bad extras) still leaves the perf # run's result_summary.json / report.txt on disk instead of discarding them — - # then the exception propagates as before. A ^C landing anywhere in - # finalization (the governor raises KeyboardInterrupt once the run task is - # done) is different: a user abort makes the whole run invalid, so the - # report is rewritten interrupted/complete:false and re-persisted by the - # outer handler below — the metrics stay in the file as partial - # diagnostics — and the interrupt propagates for exit 130. + # then the exception propagates as before. A ^C landing here (the governor + # raises KeyboardInterrupt once the run task is done) is different: a user + # abort makes the whole run invalid, so the report is rewritten + # interrupted/complete:false before the `finally` persists it — the metrics + # stay in the file as partial diagnostics — and the interrupt propagates + # for exit 130. accuracy_scores: list[dict[str, Any]] = [] try: - try: - if aborted: - # Phases may never have started (scorer init KeyErrors on - # missing sample maps) and partial phases would yield - # misleading subset scores; the scoring artifacts above are - # still on disk for inspection. - logger.warning( - "Run aborted (%s) — skipping accuracy scoring on partial data", - "run timeout" if bench.run_timed_out else "user interrupt", - ) - else: - accuracy_scores = score_accuracy(ctx, result) - finally: - # Attach the per-dataset accuracy list so result_summary.json, - # the console summary, and report.txt all carry it (stays [] on - # a scoring failure). - if report is not None: - report = msgspec.structs.replace(report, accuracy=accuracy_scores) - # Display the report + write result_summary.json / report.txt. - if report is not None: - _write_report_artifacts(ctx, report, bench.profiling) - - _summarize_and_log_metrics(ctx, report, result, collector) - - # Sibling profiling.json — kept separate so Report stays a pure - # snapshot-derived struct. Written after the report artifacts (and - # best-effort) so an OSError here can't discard the already-written - # perf report. - if bench.profiling is not None: - try: - (ctx.report_dir / "profiling.json").write_text( - json.dumps(bench.profiling, indent=2) - ) - except OSError as e: - logger.warning("Failed to write profiling.json: %s", e) - - # Emit the accuracy results as a focused artifact under accuracy/. - # Written after the report artifacts so a write failure here can't - # discard them. - write_accuracy_results(ctx.report_dir, accuracy_scores) + if aborted: + # Phases may never have started (scorer init KeyErrors on missing + # sample maps) and partial phases would yield misleading subset + # scores; the scoring artifacts above are still on disk for + # inspection. + logger.warning( + "Run aborted (%s) — skipping accuracy scoring on partial data", + "run timeout" if bench.run_timed_out else "user interrupt", + ) + else: + accuracy_scores = score_accuracy(ctx, result) except KeyboardInterrupt: - # ^C anywhere in finalization: the run is invalid. Rewrite and - # re-persist the summary (overwriting a COMPLETE one the finally may - # already have written), then propagate for exit 130. - if report is not None and report.state != "interrupted": - invalidated = msgspec.structs.replace( + if report is not None: + report = msgspec.structs.replace( report, complete=False, state="interrupted" ) - _write_report_artifacts(ctx, invalidated, bench.profiling) - report = invalidated - bench.report = report + bench.report = report raise - + finally: + # Attach the per-dataset accuracy list so result_summary.json, the + # console summary, and report.txt all carry it (stays [] on a scoring + # failure). + if report is not None: + report = msgspec.structs.replace(report, accuracy=accuracy_scores) + # Display the report + write result_summary.json / report.txt. + if report is not None: + _write_report_artifacts(ctx, report, bench.profiling) bench.report = report + _summarize_and_log_metrics(ctx, report, result, collector) + + # Sibling profiling.json — kept separate so Report stays a pure snapshot- + # derived struct. Written after the report artifacts (and best-effort) so + # an OSError here can't discard the already-written perf report. + if bench.profiling is not None: + try: + (ctx.report_dir / "profiling.json").write_text( + json.dumps(bench.profiling, indent=2) + ) + except OSError as e: + logger.warning("Failed to write profiling.json: %s", e) + + # Emit the accuracy results as a focused artifact under accuracy/. Written + # after the report artifacts so a write failure here can't discard them. + write_accuracy_results(ctx.report_dir, accuracy_scores) + def run_benchmark( config: BenchmarkConfig, @@ -1331,9 +1322,9 @@ def run_benchmark( "no services were started" ) bench = run_benchmark_async(ctx, deadline=deadline, sigint=sigint) - # A ^C can land between the coroutine's own flag snapshot and the - # loop returning — refresh from the governor so finalization never - # writes COMPLETE artifacts for a run that exits 130. + # A ^C can land between the coroutine's flag snapshot and the loop + # returning — refresh so finalization never writes COMPLETE + # artifacts for a run that exits 130. bench.user_interrupted = bench.user_interrupted or sigint.interrupted finalize_benchmark(ctx, bench) if bench.user_interrupted or sigint.interrupted: diff --git a/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py b/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py index a561bbb7c..530435ce0 100644 --- a/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py +++ b/tests/unit/async_utils/services/metrics_aggregator/test_token_metrics.py @@ -16,10 +16,7 @@ """Tests for BatchTokenizer and TokenBatchQueue.""" import asyncio -import ctypes import multiprocessing -import signal -import sys import time from concurrent.futures import Future, ProcessPoolExecutor from concurrent.futures.process import BrokenProcessPool @@ -994,24 +991,3 @@ def test_terminate_procs_kills_running_workers(): ex.shutdown(wait=False, cancel_futures=True) if manager_thread is not None: manager_thread.join(5) - - -def _report_pdeathsig() -> int: - """Arm the guard in this (child) process and read PR_GET_PDEATHSIG back.""" - token_metrics_module._install_parent_death_signal() - sig = ctypes.c_int() - libc = ctypes.CDLL(None, use_errno=True) - PR_GET_PDEATHSIG = 2 - libc.prctl(PR_GET_PDEATHSIG, ctypes.byref(sig), 0, 0, 0) - return sig.value - - -@pytest.mark.unit -def test_worker_parent_death_signal_is_sigkill(): - """A tokenizer shard must die with its parent (aggregator SIGKILLed by - the run-watchdog / ^C teardown-grace escalation): the initializer arms - PR_SET_PDEATHSIG=SIGKILL so no shard can outlive the run.""" - if not sys.platform.startswith("linux"): - pytest.skip("PR_SET_PDEATHSIG is Linux-only") - with ProcessPoolExecutor(max_workers=1) as ex: - assert ex.submit(_report_pdeathsig).result(timeout=30) == signal.SIGKILL From 1c3e4bcc8a1a323b6569f08aa6edacf4f0f79a79 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 17:14:43 -0700 Subject: [PATCH 38/45] refactor: fold watchdog module back into execute.py; teardown grace is a config knob Size pass: PerfPhaseTimeout returns to its original execute.py location (verbatim, as _PerfPhaseTimeout) and SigintGovernor/sigint_policy/ RunWatchdog live beside it - the separate watchdog.py module double- counted the moved code in the diff. Multi-line rationale comments compressed to their load-bearing core. The teardown grace is now settings.timeouts.teardown_grace_s (default 30; None = never abandon; 0 = abandon immediately) consumed by both the ^C governor and the run watchdog; the wedged-drain integration test shrinks it via YAML instead of a python -c constant override. The five per-drain CLI alias flags are dropped - the auto-generated dotted flags remain and the docs table now shows those spellings; --timeout stays. Non-test churn: 1694 -> 1514. --- AGENTS.md | 1 - docs/CLI_QUICK_REFERENCE.md | 30 +- .../services/metrics_aggregator/DESIGN.md | 2 +- .../services/metrics_aggregator/snapshot.py | 15 +- src/inference_endpoint/commands/audit.py | 14 +- .../commands/benchmark/execute.py | 325 ++++++++++++++---- .../commands/benchmark/pipeline.py | 8 +- .../commands/benchmark/watchdog.py | 289 ---------------- src/inference_endpoint/config/schema.py | 53 +-- .../templates/concurrency_template_full.yaml | 1 + .../templates/offline_template_full.yaml | 1 + .../templates/online_template_full.yaml | 1 + tests/integration/commands/test_sigint.py | 37 +- tests/unit/commands/test_watchdog.py | 32 +- tests/unit/config/test_timeouts.py | 4 + 15 files changed, 340 insertions(+), 473 deletions(-) delete mode 100644 src/inference_endpoint/commands/benchmark/watchdog.py diff --git a/AGENTS.md b/AGENTS.md index 19ecae149..7c196f3d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,7 +179,6 @@ src/inference_endpoint/ │ │ ├── __init__.py │ │ ├── cli.py # benchmark_app: offline, online, from-config subcommands │ │ ├── execute.py # Phased orchestration: setup_benchmark/run_benchmark_async/finalize_benchmark + BenchmarkContext; run_benchmark runs the main benchmark (cli._run dispatches run_audit when audit: is set) -│ │ ├── watchdog.py # PerfPhaseTimeout (perf-phase cap) + RunWatchdog (whole-run deadline) event-loop timers │ │ ├── profiling.py # Profiler-trigger protocol (vLLM /start_profile,/stop_profile) + ProfileController (URL derivation + start/stop/payload lifecycle) │ │ ├── accuracy.py # AccuracyConfiguration + per-dataset scoring (_score_accuracy, OSL/response-count rollups, write_accuracy_results) │ │ └── pipeline.py # MetricsPipeline: async context manager for the ZMQ + metrics-aggregator/event-logger subprocess lifecycle (__aenter__/__aexit__/start/drain_and_build_report) + snapshot→Report diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 52a575705..450267072 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -159,19 +159,20 @@ run_benchmark ── run_timeout_s deadline captured here ─────── deadline-bounded) ``` -| YAML path | CLI flag | Semantics | -| ----------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.min_duration_ms` | `--runtime.min-duration-ms` | Poisson only (requires explicit `target_qps`). Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`). Explicit `--num-samples` wins; both unset = issue the dataset once | -| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | -| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | -| `settings.timeouts.service_ready_timeout_s` | `--service-ready-timeout` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | -| `settings.timeouts.warmup_drain_timeout_s` | `--warmup-drain-timeout` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | -| `settings.timeouts.performance_drain_timeout_s` | `--performance-drain-timeout` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | -| `settings.timeouts.accuracy_drain_timeout_s` | `--accuracy-drain-timeout` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.metrics_drain_timeout_s` | `--metrics-drain-timeout` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | -| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | -| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | -| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | +| YAML path | CLI flag | Semantics | +| ----------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.min_duration_ms` | `--runtime.min-duration-ms` | Poisson only (requires explicit `target_qps`). Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`). Explicit `--num-samples` wins; both unset = issue the dataset once | +| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | +| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | +| `settings.timeouts.service_ready_timeout_s` | `--settings.timeouts.service-ready-timeout-s` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | +| `settings.timeouts.warmup_drain_timeout_s` | `--settings.timeouts.warmup-drain-timeout-s` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | +| `settings.timeouts.performance_drain_timeout_s` | `--settings.timeouts.performance-drain-timeout-s` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | +| `settings.timeouts.accuracy_drain_timeout_s` | `--settings.timeouts.accuracy-drain-timeout-s` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.metrics_drain_timeout_s` | `--settings.timeouts.metrics-drain-timeout-s` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | +| `settings.timeouts.teardown_grace_s` | `--settings.timeouts.teardown-grace-s` | Grace after an abort (^C or `run_timeout_s`) before a still-running metrics drain is abandoned, SIGTERM→SIGKILL (default 30; null = never abandon) | +| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | +| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | +| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | How the knobs compose: @@ -195,7 +196,8 @@ One handler owns SIGINT for the whole run, with one behavior: drains are released, buffered samples still reach the metrics aggregator, and the artifacts land honest — `result_summary.json` `state: interrupted`, `complete: false`, `events.jsonl` flushed. Exit 130. Teardown is bounded: - if the metrics drain has not finished within 30 s of the ^C, the service + if the metrics drain has not finished within `timeouts.teardown_grace_s` + (default 30 s; null = never abandon) of the ^C, the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed — a wedged drain can never hang the abort. - **Repeat ^C**: logged no-op — the stop is already in flight and the grace diff --git a/docs/async_utils/services/metrics_aggregator/DESIGN.md b/docs/async_utils/services/metrics_aggregator/DESIGN.md index 6a301c28e..f78330e67 100644 --- a/docs/async_utils/services/metrics_aggregator/DESIGN.md +++ b/docs/async_utils/services/metrics_aggregator/DESIGN.md @@ -124,7 +124,7 @@ COMPLETE event ─► trigger.fire ─► queue.enqueue(text, on_count) [ and `4`) so the service is launchable by hand without tuning knobs, but the config schema is the single source of truth (`settings.timeouts.metrics_drain_timeout_s` in `config/schema.py`, `settings.metrics_tokenizer_workers` in `config/schema.py`): the benchmark always -forwards the schema values (`--metrics-drain-timeout`, +forwards the schema values (`settings.timeouts.metrics_drain_timeout_s`, `--metrics-tokenizer-workers`), overriding these defaults in normal runs. ## References diff --git a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py index 697be12fd..5f382319a 100644 --- a/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py +++ b/src/inference_endpoint/async_utils/services/metrics_aggregator/snapshot.py @@ -53,16 +53,11 @@ class SessionState(str, Enum): COMPLETE → terminal clean state. The ``publish_final()`` snapshot written from the ``ENDED`` path. Percentiles and histograms are exact (computed from raw values). - INTERRUPTED → terminal interrupted state. Entered when the session's - INTERRUPTED marker event preceded ``ENDED`` (a graceful ^C - stops the session, which still publishes ENDED), or when - SIGTERM landed (run watchdog escalation) before ``ENDED``. - SIGINT itself is ignored — the parent's ENDED path is - authoritative for ^C. Stats are best-effort partial - captures — the drain didn't complete and raw values may be - missing late samples. Distinguishes "run aborted" from - "clean shutdown"; Report renders this with a clear - interrupted indicator. + INTERRUPTED → terminal interrupted state: the session's INTERRUPTED + marker preceded ``ENDED``, or SIGTERM landed first (run + watchdog / teardown grace). SIGINT itself is ignored — the + parent's ENDED path is authoritative for ^C. Stats are + best-effort partial captures. Transitions are forward-only: INITIALIZE → LIVE → DRAINING → {COMPLETE | INTERRUPTED} diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index 86c849053..1ed8c70f3 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -38,13 +38,14 @@ from ..exceptions import ExecutionError, SetupError from .benchmark.execute import ( BenchmarkResult, + SigintGovernor, TestMode, _salvage_tmpfs, finalize_benchmark, run_benchmark_async, setup_benchmark, + sigint_policy, ) -from .benchmark.watchdog import SigintGovernor, sigint_policy logger = logging.getLogger(__name__) @@ -77,13 +78,10 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: specs = test.plan_runs(audit_cfg) - # One SIGINT policy for the whole audit — same governor pattern as - # run_benchmark (which restored the previous handler before run_audit - # started). First ^C stops the current phase gracefully; the phase then - # surfaces as report.state=="interrupted" below and aborts the audit. - # The flag persisting across phases is moot: an interrupted phase raises - # before the next phase starts. - sigint = SigintGovernor() + # One SIGINT policy for the whole audit (same pattern as run_benchmark): + # first ^C stops the current phase gracefully, which surfaces as + # report.state=="interrupted" and aborts the audit. + sigint = SigintGovernor(config.settings.timeouts.teardown_grace_s) with sigint_policy(sigint): artifacts = _run_phases(config, base_report_dir, test, audit_cfg, specs, sigint) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index ab6b6885b..5ea03d61a 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -28,14 +28,17 @@ from __future__ import annotations import asyncio +import contextlib import json import logging import random import shutil +import signal import tempfile import time +import types import uuid -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import dataclass, field from dataclasses import replace as dataclass_replace from datetime import datetime @@ -61,12 +64,6 @@ ProfileController, write_profiling_section, ) -from inference_endpoint.commands.benchmark.watchdog import ( - PerfPhaseTimeout, - RunWatchdog, - SigintGovernor, - sigint_policy, -) from inference_endpoint.compliance import AuditRunSpec from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( @@ -685,6 +682,225 @@ def _build_phases( return phases +class _PerfPhaseTimeout: + """Session-stop timer that bounds the PERFORMANCE phase only. + + ``max_duration_ms`` is a safety cap on the performance phase. The timer is + armed when the performance phase starts and cancelled as soon as any later + phase starts, so it can never truncate a subsequent accuracy phase: a + combined perf+accuracy run must let accuracy finish regardless of how long + perf ran. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + max_duration_ms: int | None, + on_timeout: Callable[[], None], + ) -> None: + self._loop = loop + self._max_duration_ms = max_duration_ms + self._on_timeout = on_timeout + self._handle: asyncio.TimerHandle | None = None + + def on_phase_start(self, phase_type: PhaseType) -> None: + self.cancel() + if phase_type == PhaseType.PERFORMANCE and self._max_duration_ms is not None: + self._handle = self._loop.call_later( + self._max_duration_ms / 1000.0, self._on_timeout + ) + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None + + +class SigintGovernor: + """The run's single SIGINT policy — installed ONCE per run. + + One handler covers the whole run (setup, session, drain, finalize); the + window-scoped install/remove pairs it replaces were exactly where a ^C + could slip through as a raw KeyboardInterrupt mid-teardown. Behavior is + keystroke-count-independent (group-SIGINT forwarders like ``uv run`` need + no special handling): no live run -> raise KeyboardInterrupt (exit 130); + live run -> graceful ``session.stop()`` plus a teardown grace timer that + abandons a still-wedged metrics drain (SIGTERM -> SIGKILL); any repeat ^C + is a logged no-op. + """ + + def __init__(self, teardown_grace_s: float | None) -> None: + self.interrupted = False + self._teardown_grace_s = teardown_grace_s + self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._on_grace_expiry: Callable[[], None] | None = None + self._grace_handle: asyncio.TimerHandle | None = None + + def bind_task( + self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop + ) -> None: + """Bind the run coroutine's task — the live-run gate for the graceful path.""" + self._task = task + self._loop = loop + + def bind_session( + self, session: BenchmarkSession, on_grace_expiry: Callable[[], None] + ) -> None: + self._session = session + self._on_grace_expiry = on_grace_expiry + + def cancel_grace(self) -> None: + """Disarm the teardown grace timer (drain finished on its own).""" + if self._grace_handle is not None: + self._grace_handle.cancel() + self._grace_handle = None + + def _stop_gracefully(self) -> None: + """Runs on the loop: stop the session and bound the teardown.""" + assert self._session is not None and self._loop is not None + self._session.stop() + if ( + self._on_grace_expiry is not None + and self._teardown_grace_s is not None + and self._grace_handle is None + ): + + def _expire() -> None: + logger.warning( + "Teardown did not finish within %.0fs of ^C — abandoning " + "the metrics drain", + self._teardown_grace_s, + ) + assert self._on_grace_expiry is not None + self._on_grace_expiry() + + self._grace_handle = self._loop.call_later(self._teardown_grace_s, _expire) + + def __call__(self, signum: int, frame: types.FrameType | None) -> None: + if self.interrupted: + # Stop already in flight; the grace timer bounds the teardown. + logger.warning("SIGINT again: shutdown already in progress") + return + self.interrupted = True + if ( + self._session is None + or self._task is None + or self._task.done() + or self._loop is None + or not self._loop.is_running() + ): + # No live run to stop gracefully; call_soon_threadsafe on a + # stopped loop would silently swallow the ^C. + raise KeyboardInterrupt + logger.warning("SIGINT received: stopping benchmark gracefully") + # Signal handlers run at arbitrary bytecode boundaries: hand the stop + # to the loop via its one signal-safe entry point. + self._loop.call_soon_threadsafe(self._stop_gracefully) + + +@contextlib.contextmanager +def sigint_policy(governor: SigintGovernor) -> Iterator[None]: + """Install ``governor`` as the SIGINT handler; restore the previous one. + + Passive when ``getsignal`` returns ``None`` (a C-installed handler that + ``signal.signal`` refuses back) or off the main thread. Restores on exit, + after the caller's finally blocks, so a repeat ^C during cleanup still + hits the governor's no-op. + """ + prev = signal.getsignal(signal.SIGINT) + if prev is None: + yield + return + try: + signal.signal(signal.SIGINT, governor) + except ValueError: + yield + return + try: + yield + finally: + signal.signal(signal.SIGINT, prev) + + +class RunWatchdog: + """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. + + Armed before the pipeline starts and kept armed through the metrics + drain. On fire with a session: stop it (ENDED still flows, the event + logger flushes) and SIGTERM the aggregator, whose handler writes the + INTERRUPTED final snapshot; if the aggregator ignores the SIGTERM, the + teardown grace SIGTERM->SIGKILLs the children so the deadline stays a + hard bound. Before the session exists the orchestration task is + cancelled instead, and ``MetricsPipeline.__aexit__`` kills the services. + ``run_benchmark`` raises whenever ``fired`` is set. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + deadline: float | None, + pipe: MetricsPipeline, + teardown_grace_s: float | None, + ) -> None: + self.fired = False + self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None + self._pipe = pipe + self._loop = loop + self._teardown_grace_s = teardown_grace_s + self._escalation: asyncio.TimerHandle | None = None + self._handle = ( + loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) + if deadline is not None + else None + ) + + def bind_task(self, task: asyncio.Task | None) -> None: + """Bind the orchestration task — the pre-session cancellation target.""" + self._task = task + + def bind_session(self, session: BenchmarkSession) -> None: + """Late-bind the session; a deadline that already fired stops it now. + + The caller still runs the stopped session so STARTED/ENDED flow and + the INTERRUPTED artifacts get written. + """ + self._session = session + if self.fired: + session.stop() + + def _fire(self) -> None: + self.fired = True + logger.error( + "Run timeout reached; aborting run — report will be marked INTERRUPTED." + ) + if self._session is None: + # Still in service launch / endpoint connect: cancel the task so + # those awaits unwind now; _run_benchmark_async translates the + # unwind into the run-timeout ExecutionError. + if self._task is not None: + self._task.cancel() + return + self._session.stop() + self._pipe.terminate_metrics_aggregator() + if self._teardown_grace_s is not None: + # A wedged aggregator ignores the SIGTERM; escalate so the + # deadline stays a hard bound (cancelled when the drain finishes). + self._escalation = self._loop.call_later( + self._teardown_grace_s, self._pipe.abandon_drain + ) + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None + if self._escalation is not None: + self._escalation.cancel() + self._escalation = None + + async def _create_issuer( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop ) -> tuple[HttpClientSampleIssuer, HTTPEndpointClient]: @@ -818,7 +1034,9 @@ async def _run_benchmark_async( # idempotent, so the clean-path shutdown below is a harmless second call. http_client: HTTPEndpointClient | None = None - watchdog = RunWatchdog(loop, deadline, pipe) + watchdog = RunWatchdog( + loop, deadline, pipe, config.settings.timeouts.teardown_grace_s + ) watchdog.bind_task(asyncio.current_task()) if sigint is not None: sigint.bind_task(asyncio.current_task(), loop) @@ -861,11 +1079,8 @@ async def _run_benchmark_async( ) watchdog.bind_session(session) if sigint is not None: - # On ^C the governor stops the session and arms a teardown - # grace timer; expiry SIGTERMs the aggregator (its handler - # writes a best-effort INTERRUPTED snapshot, terminate_all - # escalates to SIGKILL) so a wedged drain can't hang the - # abort. + # ^C: graceful stop + grace timer; expiry abandons a + # wedged drain (SIGTERM→SIGKILL via abandon_drain). sigint.bind_session(session, pipe.abandon_drain) phases = _build_phases(ctx, perf_strategy=agentic_inference_strategy) @@ -889,7 +1104,7 @@ def _on_perf_phase_timeout() -> None: # perf cap. session.stop_current_phase() - perf_timeout = PerfPhaseTimeout( + perf_timeout = _PerfPhaseTimeout( loop, max_duration_ms, _on_perf_phase_timeout ) @@ -943,20 +1158,12 @@ def _on_phase_start(phase: PhaseConfig) -> None: finally: _perf_cap_done = True perf_timeout.cancel() - # NOTE: no SIGINT bookkeeping here — the process-level - # SigintGovernor (installed once by run_benchmark) covers - # the metrics drain below too. - # Fire /stop_profile for URLs whose /start_profile succeeded. - # Unifies the clean phase-end path and the abort path — both - # reach this block. A watchdog abort counts as an abort even - # when session.run returned normally after session.stop(). + # Fire /stop_profile for starts that succeeded (clean and + # abort paths both reach this block). profiler.stop(session_completed_normally and not watchdog.fired) - # Graceful drain runs on both the clean-finish and - # session-failure paths (BenchmarkSession.run publishes - # ENDED in its own finally, so a failed run still has a - # terminal snapshot worth draining). Nulls pipe.publisher - # so __aexit__ releases the ZMQ scope without killing the - # services. + # Drain runs on clean-finish and session-failure paths + # alike (ENDED flows either way); nulls pipe.publisher so + # __aexit__ doesn't kill the services. try: report = await pipe.drain_and_build_report() if report is None: @@ -964,15 +1171,10 @@ def _on_phase_start(phase: PhaseConfig) -> None: "Benchmark completed without a usable " "metrics report" ) except Exception as e: # noqa: BLE001 - # On a clean run a drain / report-build failure must - # be loud: silently returning report=None would exit - # 0 with no perf artifacts. On the session-failure - # path the run is already raising, so swallow it - # there rather than let a teardown error replace the - # in-flight exception; run_benchmark still fails the - # run on a missing report. A ^C'd run swallows it - # too: the user's abort (exit 130) outranks a drain - # error its own grace escalation may have caused. + # Loud on a clean run (never exit 0 without a report); + # swallowed when the run is already failing or ^C'd — + # the abort (exit 130) outranks a drain error its own + # grace escalation may have caused. if session_completed_normally and not ( sigint is not None and sigint.interrupted ): @@ -1193,38 +1395,24 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: report = bench.report aborted = bench.run_timed_out or bench.user_interrupted if report is not None and aborted and report.state != "interrupted": - # Split-brain guard: an aborted run must never publish artifacts under - # any other state. "complete": the aggregator finalized before the - # watchdog's SIGTERM landed, or a ^C arrived after the session already - # published its terminal ENDED (drain window), so the INTERRUPTED - # marker never went out. "live": the teardown grace SIGKILLed a wedged - # aggregator, so the report was built from the subscriber's last live - # snapshot. Keyed on state (not the derived ``complete`` flag) so the - # drain-timeout subcase — state "complete" with pending tasks — is - # corrected too. Force both fields honest before writing; consumers - # keying on state=="complete" and not complete (the drain-timeout - # signature) then can't misattribute an abort to a slow drain. + # Split-brain guard: an aborted run must never publish artifacts + # under any other state — "complete" (aggregator finalized before the + # abort landed) or "live" (grace-killed drain, report built from the + # last live snapshot). Keyed on state, not the derived ``complete`` + # flag, so the drain-timeout subcase is corrected too. report = msgspec.structs.replace(report, complete=False, state="interrupted") - # Write back so callers holding the BenchmarkResult (the audit runner - # checks bench.report.state after each phase) see the honest state, - # not the aggregator's stale COMPLETE. + # Write back: the audit runner checks bench.report.state per phase. bench.report = report # Write scoring artifacts + copy event log from tmpfs to disk (scorers read # sample_idx_map.json + events.jsonl from here). _write_scoring_artifacts(ctx, result, bench.tmpfs_dir) - # Accuracy scoring (one entry per accuracy dataset). Scoring runs before the - # report is written so the accuracy headline can be attached, but the report - # is written in the `finally` below so a scoring failure (e.g. lcb-service - # unreachable, missing eval subproject, bad extras) still leaves the perf - # run's result_summary.json / report.txt on disk instead of discarding them — - # then the exception propagates as before. A ^C landing here (the governor - # raises KeyboardInterrupt once the run task is done) is different: a user - # abort makes the whole run invalid, so the report is rewritten - # interrupted/complete:false before the `finally` persists it — the metrics - # stay in the file as partial diagnostics — and the interrupt propagates - # for exit 130. + # Scoring runs before the report is written so the accuracy headline can + # attach; the report is written in the `finally` so a scoring failure + # still leaves the perf artifacts on disk. A ^C here makes the run + # invalid: the report is rewritten interrupted/complete:false before the + # `finally` persists it, then the interrupt propagates for exit 130. accuracy_scores: list[dict[str, Any]] = [] try: if aborted: @@ -1304,13 +1492,10 @@ def run_benchmark( # (tokenizer/dataset load) counts against run_timeout_s too. deadline = _run_deadline(config) run_timeout_s = config.settings.timeouts.run_timeout_s - # The run's ONE SIGINT handler — no window-scoped install/remove pairs - # anywhere else in the run, so there is no gap where a ^C aborts teardown - # as a raw KeyboardInterrupt. No session bound yet, so a ^C during setup - # keeps default abort behavior. sigint_policy restores the previous - # handler only after the finally below, so a repeat ^C during salvage - # still hits the governor's no-op. - sigint = SigintGovernor() + # The run's ONE SIGINT handler; sigint_policy restores the previous one + # only after the finally below, so a repeat ^C during salvage still hits + # the governor's no-op. + sigint = SigintGovernor(config.settings.timeouts.teardown_grace_s) bench: BenchmarkResult | None = None with sigint_policy(sigint): try: @@ -1322,9 +1507,7 @@ def run_benchmark( "no services were started" ) bench = run_benchmark_async(ctx, deadline=deadline, sigint=sigint) - # A ^C can land between the coroutine's flag snapshot and the loop - # returning — refresh so finalization never writes COMPLETE - # artifacts for a run that exits 130. + # ^C can land after the coroutine's own flag snapshot — refresh. bench.user_interrupted = bench.user_interrupted or sigint.interrupted finalize_benchmark(ctx, bench) if bench.user_interrupted or sigint.interrupted: diff --git a/src/inference_endpoint/commands/benchmark/pipeline.py b/src/inference_endpoint/commands/benchmark/pipeline.py index f16b6136a..f3b07677f 100644 --- a/src/inference_endpoint/commands/benchmark/pipeline.py +++ b/src/inference_endpoint/commands/benchmark/pipeline.py @@ -381,12 +381,10 @@ def terminate_metrics_aggregator(self) -> None: self._launcher.terminate_module(_AGGREGATOR_MODULE) def abandon_drain(self) -> None: - """SIGTERM→SIGKILL every service child; safe no-op before/after launch. + """SIGTERM→SIGKILL every service child; idempotent, safe pre-launch. - Teardown-grace path (^C with a wedged drain): SIGTERM gives the - aggregator its chance to write an INTERRUPTED snapshot, SIGKILL reaps - it regardless, and the drain's ``wait_for_exit`` thread unblocks once - the children are gone. Idempotent — exited children are skipped. + Teardown-grace path (abort with a wedged drain): once the children + are reaped, the drain's ``wait_for_exit`` thread unblocks. """ self._kill_services() diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py deleted file mode 100644 index 0e22abe18..000000000 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ /dev/null @@ -1,289 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Run-scoped abort machinery for the benchmark orchestrator. - -``PerfPhaseTimeout`` bounds the PERFORMANCE phase (``runtime.max_duration_ms``); -``RunWatchdog`` is the whole-run deadline (``settings.timeouts.run_timeout_s``); -``SigintGovernor`` is the run's one Ctrl-C policy. All owned by -``commands/benchmark/execute.py``. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging -import signal -import time -import types -from collections.abc import Callable, Iterator -from typing import TYPE_CHECKING - -from inference_endpoint.load_generator.session import BenchmarkSession, PhaseType - -if TYPE_CHECKING: - from inference_endpoint.commands.benchmark.pipeline import MetricsPipeline - -logger = logging.getLogger(__name__) - - -class SigintGovernor: - """The run's single SIGINT policy — installed ONCE by ``run_benchmark``. - - One ``signal.signal`` handler covers the entire run (setup, session, - metrics drain, finalize) instead of window-scoped install/remove pairs, - whose gaps are exactly where a ^C used to slip through as a raw - KeyboardInterrupt and abort teardown half-way. - - One behavior, keystroke-count-independent (so runners that forward the - terminal's group SIGINT, like ``uv run``, need no special handling): - - ^C with no live run (sync setup, finalization after the loop returned, - between audit phases): nothing to stop gracefully — raise - KeyboardInterrupt immediately (default behavior, exit 130). - - ^C with a live run: graceful — ``session.stop()``; the stopped run - publishes INTERRUPTED+ENDED, services drain, artifacts land as - state=interrupted, then ``run_benchmark`` raises for exit 130. A - teardown grace timer is armed: if the metrics drain has not finished - within ``TEARDOWN_GRACE_S``, the aggregator is SIGTERMed (its handler - writes a best-effort INTERRUPTED snapshot; ``terminate_all`` escalates - to SIGKILL) so a wedged drain can never hang the abort. - - Any repeat ^C: logged no-op — the stop is already in flight and the - grace timer bounds the teardown. - """ - - TEARDOWN_GRACE_S = 30.0 - """Seconds after a ^C before a still-running metrics drain is abandoned.""" - - def __init__(self) -> None: - self.interrupted = False - self._session: BenchmarkSession | None = None - self._task: asyncio.Task | None = None - self._loop: asyncio.AbstractEventLoop | None = None - self._on_grace_expiry: Callable[[], None] | None = None - self._grace_handle: asyncio.TimerHandle | None = None - - def bind_task( - self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop - ) -> None: - """Bind the run coroutine's task — the live-run gate for the graceful path.""" - self._task = task - self._loop = loop - - def bind_session( - self, session: BenchmarkSession, on_grace_expiry: Callable[[], None] - ) -> None: - self._session = session - self._on_grace_expiry = on_grace_expiry - - def cancel_grace(self) -> None: - """Disarm the teardown grace timer (drain finished on its own).""" - if self._grace_handle is not None: - self._grace_handle.cancel() - self._grace_handle = None - - def _stop_gracefully(self) -> None: - """Runs on the loop: stop the session and bound the teardown.""" - assert self._session is not None and self._loop is not None - self._session.stop() - if self._on_grace_expiry is not None and self._grace_handle is None: - - def _expire() -> None: - logger.warning( - "Teardown did not finish within %.0fs of ^C — abandoning " - "the metrics drain", - self.TEARDOWN_GRACE_S, - ) - assert self._on_grace_expiry is not None - self._on_grace_expiry() - - self._grace_handle = self._loop.call_later(self.TEARDOWN_GRACE_S, _expire) - - def __call__(self, signum: int, frame: types.FrameType | None) -> None: - if self.interrupted: - # Stop already in flight; the grace timer bounds the teardown. A - # forwarded duplicate delivery (uv run) lands here harmlessly too. - logger.warning("SIGINT again: shutdown already in progress") - return - self.interrupted = True - if ( - self._session is None - or self._task is None - or self._task.done() - or self._loop is None - or not self._loop.is_running() - ): - # No live run to stop gracefully. call_soon_threadsafe on a - # stopped loop would queue session.stop and never run it — - # silently swallowing the ^C. - raise KeyboardInterrupt - logger.warning("SIGINT received: stopping benchmark gracefully") - # A signal handler runs at an arbitrary bytecode boundary — possibly - # mid-event-loop-iteration. Don't mutate asyncio state (Event.set, - # call_later) from here; hand the stop to the loop, the one asyncio - # entry point documented as signal-handler safe. - self._loop.call_soon_threadsafe(self._stop_gracefully) - - -@contextlib.contextmanager -def sigint_policy(governor: SigintGovernor) -> Iterator[None]: - """Install ``governor`` as the SIGINT handler; restore the previous one. - - Stays passive (installs nothing) when ``signal.getsignal`` returns - ``None`` — a C-installed handler Python cannot represent and - ``signal.signal`` would refuse to accept back — or when installation - raises ValueError (not the main thread; embedded use). Restoration - happens on exit, after the caller's own finally blocks, so a repeat ^C - during cleanup still hits the governor's no-op instead of the default - handler. - """ - prev = signal.getsignal(signal.SIGINT) - if prev is None: - yield - return - try: - signal.signal(signal.SIGINT, governor) - except ValueError: - yield - return - try: - yield - finally: - signal.signal(signal.SIGINT, prev) - - -class PerfPhaseTimeout: - """Session-stop timer that bounds the PERFORMANCE phase only. - - ``max_duration_ms`` is a safety cap on the performance phase. The timer is - armed when the performance phase starts and cancelled as soon as any later - phase starts, so it can never truncate a subsequent accuracy phase: a - combined perf+accuracy run must let accuracy finish regardless of how long - perf ran. - """ - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - max_duration_ms: int | None, - on_timeout: Callable[[], None], - ) -> None: - self._loop = loop - self._max_duration_ms = max_duration_ms - self._on_timeout = on_timeout - self._handle: asyncio.TimerHandle | None = None - - def on_phase_start(self, phase_type: PhaseType) -> None: - self.cancel() - if phase_type == PhaseType.PERFORMANCE and self._max_duration_ms is not None: - self._handle = self._loop.call_later( - self._max_duration_ms / 1000.0, self._on_timeout - ) - - def cancel(self) -> None: - if self._handle is not None: - self._handle.cancel() - self._handle = None - - -class RunWatchdog: - """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. - - Armed before the metrics pipeline starts (so service-launch and - endpoint-connect stalls are bounded) and kept armed through the metrics - drain (so a stuck aggregator drain is bounded too). On fire, once the - session exists: stop the session (its run unwinds and publishes ENDED, so - the event logger — spared the SIGTERM — flushes and exits) and SIGTERM - the aggregator, whose handler immediately writes the INTERRUPTED final - snapshot with whatever stats it holds at that instant (``publish_final`` - is first-wins, so INTERRUPTED stays authoritative). If the aggregator - ignores the SIGTERM (wedged/unschedulable), the same teardown grace as - the ^C path SIGTERM→SIGKILLs every service child so the drain's - wait-for-exit unblocks — the deadline is a hard bound. Before the session - exists (service launch / endpoint connect still pending), stopping - nothing would let those awaits run out their own readiness timeouts past - the deadline — so the orchestration task is cancelled instead, which - unwinds them promptly and lets ``MetricsPipeline.__aexit__`` kill the - services. ``run_benchmark`` raises ``ExecutionError`` after finalization - whenever ``fired`` is set, so a timed-out run always fails loudly even if - a still-draining aggregator finalized COMPLETE first. - """ - - TEARDOWN_GRACE_S = SigintGovernor.TEARDOWN_GRACE_S - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - deadline: float | None, - pipe: MetricsPipeline, - ) -> None: - self.fired = False - self._session: BenchmarkSession | None = None - self._task: asyncio.Task | None = None - self._pipe = pipe - self._loop = loop - self._escalation: asyncio.TimerHandle | None = None - self._handle = ( - loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) - if deadline is not None - else None - ) - - def bind_task(self, task: asyncio.Task | None) -> None: - """Bind the orchestration task — the pre-session cancellation target.""" - self._task = task - - def bind_session(self, session: BenchmarkSession) -> None: - """Late-bind the session: it is created after the timer is armed. - - A deadline that already fired stops the session immediately, so no - load is ever issued past it — the caller still runs the stopped - session so STARTED/ENDED flow (the event logger exits only on ENDED) - and the INTERRUPTED artifacts get written. - """ - self._session = session - if self.fired: - session.stop() - - def _fire(self) -> None: - self.fired = True - logger.error( - "Run timeout reached; aborting run — report will be marked " "INTERRUPTED." - ) - if self._session is None: - # Still in service launch / endpoint connect: cancel the - # orchestration task so those awaits unwind now instead of - # running out their own readiness timeouts past the deadline. - # No load was issued, so there are no artifacts to preserve; - # _run_benchmark_async translates the unwind into the run-timeout - # ExecutionError. - if self._task is not None: - self._task.cancel() - return - self._session.stop() - self._pipe.terminate_metrics_aggregator() - # A wedged aggregator ignores the SIGTERM; escalate so the deadline - # stays a hard bound (cancelled by cancel() when the drain finishes). - self._escalation = self._loop.call_later( - self.TEARDOWN_GRACE_S, self._pipe.abandon_drain - ) - - def cancel(self) -> None: - if self._handle is not None: - self._handle.cancel() - self._handle = None - if self._escalation is not None: - self._escalation.cancel() - self._escalation = None diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index f6311d1c1..7fd6f2a4c 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -844,46 +844,33 @@ class Timeouts(WithUpdatesMixin, BaseModel): "INTERRUPTED, and exits non-zero. Never derives per-stage deadlines." ), ) - service_ready_timeout_s: Annotated[ - float, - cyclopts.Parameter( - alias="--service-ready-timeout", - help="Seconds to wait for metrics/event-logger services to start", + teardown_grace_s: float | None = Field( + 30.0, + ge=0, + description=( + "Seconds after an abort (^C or a run_timeout_s fire) before a " + "still-running metrics drain is abandoned: the service children " + "are SIGTERMed (the aggregator writes a best-effort INTERRUPTED " + "snapshot) then SIGKILLed, so a wedged drain can never hang the " + "abort (None = never abandon; 0 = abandon immediately)." ), - ] = Field( + ) + service_ready_timeout_s: float = Field( 30.0, ge=0, description="Seconds to wait for metrics-aggregator/event-logger services to become ready.", ) - warmup_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--warmup-drain-timeout", - help="Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", - ), - ] = Field( + warmup_drain_timeout_s: float | None = Field( 240.0, ge=0, description="Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ) - performance_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--performance-drain-timeout", - help="Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", - ), - ] = Field( + performance_drain_timeout_s: float | None = Field( None, ge=0, description="Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", ) - accuracy_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--accuracy-drain-timeout", - help="Accuracy drain timeout in seconds (None = wait indefinitely; 0 = skip the drain)", - ), - ] = Field( + accuracy_drain_timeout_s: float | None = Field( None, ge=0, description=( @@ -892,17 +879,7 @@ class Timeouts(WithUpdatesMixin, BaseModel): "every sample must complete)" ), ) - metrics_drain_timeout_s: Annotated[ - float | None, - cyclopts.Parameter( - alias="--metrics-drain-timeout", - help=( - "Wall-clock budget (seconds) for the metrics aggregator to finish " - "tokenizing buffered samples after the run ends " - "(None = wait indefinitely; 0 = give up immediately)" - ), - ), - ] = Field( + metrics_drain_timeout_s: float | None = Field( None, ge=0, description=( diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index b8127072d..4dfa7ba5a 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -86,6 +86,7 @@ settings: worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index 4454e922d..ddaef1168 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -86,6 +86,7 @@ settings: worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 4db18e09f..e9bafe8a9 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -87,6 +87,7 @@ settings: worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. + teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index f2dc38b2a..1f05fceae 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -56,8 +56,18 @@ _DS_DATASET = _TESTS_DIR / "assets/datasets/ds_samples.jsonl" -def _write_config(report_dir: Path, endpoint_url: str, config_path: Path) -> None: +def _write_config( + report_dir: Path, + endpoint_url: str, + config_path: Path, + teardown_grace_s: float | None = None, +) -> None: """~120 s workload (600 samples @ 5 QPS): only the ^C can end the run.""" + grace = ( + f"\n timeouts:\n teardown_grace_s: {teardown_grace_s}" + if teardown_grace_s is not None + else "" + ) config_path.write_text( f""" type: online @@ -81,7 +91,7 @@ def _write_config(report_dir: Path, endpoint_url: str, config_path: Path) -> Non runtime: n_samples_to_issue: 600 warmup: - enabled: false + enabled: false{grace} """ ) @@ -218,30 +228,19 @@ def test_sigint_grace_expiry_abandons_wedged_drain(mock_http_echo_server, tmp_pa (``os.kill``, not the group): the graceful stop parks on the wedged drain; grace expiry must SIGTERM→SIGKILL the children so the drain's wait-for-exit unblocks and the run exits 130 without a second keystroke. - The grace is shrunk to 3s via the class constant (fixed 30s in - production) so the test stays fast. + The grace is shrunk to 3s (settings.timeouts.teardown_grace_s; default + 30) so the test stays fast. """ report_dir = tmp_path / "report" config_path = tmp_path / "bench.yaml" - _write_config(report_dir, mock_http_echo_server.url, config_path) - - wrapper = ( - "from inference_endpoint.commands.benchmark.watchdog import SigintGovernor; " - "SigintGovernor.TEARDOWN_GRACE_S = 3.0; " - "from inference_endpoint.main import run; run()" + _write_config( + report_dir, mock_http_echo_server.url, config_path, teardown_grace_s=3.0 ) + agg_pid: int | None = None try: with _benchmark_proc( - [ - shutil.which("python") or "python", - "-c", - wrapper, - "benchmark", - "from-config", - "-c", - str(config_path), - ] + [_cli(), "benchmark", "from-config", "-c", str(config_path)] ) as proc: _wait_services_ready(proc, report_dir) time.sleep(3.0) # comfortably inside the ~120 s performance phase diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index 15b6d267e..c46eea1bc 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -22,9 +22,9 @@ from unittest.mock import MagicMock import pytest -from inference_endpoint.commands.benchmark.watchdog import ( - PerfPhaseTimeout, +from inference_endpoint.commands.benchmark.execute import ( SigintGovernor, + _PerfPhaseTimeout, sigint_policy, ) from inference_endpoint.load_generator.session import PhaseType @@ -37,7 +37,7 @@ def _fire(gov: SigintGovernor) -> None: @pytest.mark.unit class TestSigintGovernor: def test_unbound_sigint_raises_keyboard_interrupt(self): - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) with pytest.raises(KeyboardInterrupt): _fire(gov) assert gov.interrupted @@ -49,7 +49,7 @@ def test_sigint_after_loop_returned_raises_immediately(self): bound but the loop is stopped — ``call_soon_threadsafe`` would queue ``session.stop`` on it and never run it. """ - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) session = MagicMock() async def run_phase() -> None: @@ -65,7 +65,7 @@ async def run_phase() -> None: @pytest.mark.asyncio async def test_live_sigint_stops_session_and_arms_grace(self): - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) session = MagicMock() on_grace = MagicMock() gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) @@ -82,7 +82,7 @@ async def test_live_sigint_stops_session_and_arms_grace(self): @pytest.mark.asyncio async def test_repeat_sigint_is_a_noop(self): """Any repeat ^C (incl. a forwarded duplicate under `uv run`) is silent.""" - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) session = MagicMock() gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) gov.bind_session(session, MagicMock()) @@ -97,8 +97,7 @@ async def test_repeat_sigint_is_a_noop(self): @pytest.mark.asyncio async def test_grace_expiry_fires_callback_once(self): """The grace fires exactly once, even after repeat ^C deliveries.""" - gov = SigintGovernor() - gov.TEARDOWN_GRACE_S = 0.02 # instance override; class default untouched + gov = SigintGovernor(teardown_grace_s=0.02) session = MagicMock() fired = asyncio.Event() on_grace = MagicMock(side_effect=fired.set) @@ -114,8 +113,7 @@ async def test_grace_expiry_fires_callback_once(self): @pytest.mark.asyncio async def test_cancel_grace_disarms_pending_timer(self): - gov = SigintGovernor() - gov.TEARDOWN_GRACE_S = 0.02 + gov = SigintGovernor(teardown_grace_s=0.02) session = MagicMock() on_grace = MagicMock() gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) @@ -132,14 +130,14 @@ async def test_cancel_grace_disarms_pending_timer(self): @pytest.mark.unit class TestSigintPolicy: def test_installs_and_restores_previous_handler(self): - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) prev = signal.getsignal(signal.SIGINT) with sigint_policy(gov): assert signal.getsignal(signal.SIGINT) is gov assert signal.getsignal(signal.SIGINT) is prev def test_restores_on_exception(self): - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) prev = signal.getsignal(signal.SIGINT) with pytest.raises(RuntimeError): with sigint_policy(gov): @@ -148,7 +146,7 @@ def test_restores_on_exception(self): def test_unrepresentable_c_handler_stays_untouched(self, monkeypatch): """getsignal()->None (C-installed handler): install nothing at all.""" - gov = SigintGovernor() + gov = SigintGovernor(teardown_grace_s=30.0) monkeypatch.setattr(signal, "getsignal", lambda signum: None) install_spy = MagicMock() monkeypatch.setattr(signal, "signal", install_spy) @@ -168,7 +166,7 @@ class TestPerfPhaseTimeout: @pytest.mark.asyncio async def test_cap_fires_after_max_duration(self): fired = asyncio.Event() - timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + timeout = _PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) timeout.on_phase_start(PhaseType.PERFORMANCE) @@ -177,7 +175,7 @@ async def test_cap_fires_after_max_duration(self): @pytest.mark.asyncio async def test_accuracy_phase_start_disarms_pending_perf_cap(self): fired = asyncio.Event() - timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + timeout = _PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) timeout.on_phase_start(PhaseType.PERFORMANCE) timeout.on_phase_start(PhaseType.ACCURACY) @@ -199,7 +197,7 @@ async def test_accuracy_phase_start_disarms_pending_perf_cap(self): ) async def test_never_armed(self, max_duration_ms, phases): fired = asyncio.Event() - timeout = PerfPhaseTimeout( + timeout = _PerfPhaseTimeout( asyncio.get_running_loop(), max_duration_ms, fired.set ) @@ -212,7 +210,7 @@ async def test_never_armed(self, max_duration_ms, phases): @pytest.mark.asyncio async def test_cancel_is_idempotent_and_disarms(self): fired = asyncio.Event() - timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + timeout = _PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) timeout.cancel() # no handle yet — must not raise timeout.on_phase_start(PhaseType.PERFORMANCE) diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index 36080491f..1c7256efd 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -48,6 +48,7 @@ def test_defaults(self): assert cfg.run_timeout_s is None assert cfg.service_ready_timeout_s == 30.0 assert cfg.warmup_drain_timeout_s == 240.0 + assert cfg.teardown_grace_s == 30.0 assert cfg.performance_drain_timeout_s is None assert cfg.accuracy_drain_timeout_s is None assert cfg.metrics_drain_timeout_s is None @@ -74,6 +75,7 @@ class TestTimeoutsValidation: ("run_timeout_s", 0), ("run_timeout_s", -1.0), ("warmup_drain_timeout_s", -1.0), + ("teardown_grace_s", -1.0), ("performance_drain_timeout_s", -1.0), ("accuracy_drain_timeout_s", -1.0), ("metrics_drain_timeout_s", -1.0), @@ -94,6 +96,7 @@ def test_deadline_must_be_positive_or_none(self, field, value): "performance_drain_timeout_s", "accuracy_drain_timeout_s", "metrics_drain_timeout_s", + "teardown_grace_s", ], ) def test_zero_drain_budget_is_valid(self, field): @@ -109,6 +112,7 @@ def test_zero_drain_budget_is_valid(self, field): "performance_drain_timeout_s", "accuracy_drain_timeout_s", "metrics_drain_timeout_s", + "teardown_grace_s", ], ) def test_deadline_none_means_unlimited(self, field): From 30009d254ee9ea0bf0c92df41346235ac8b2d367 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 18:47:33 -0700 Subject: [PATCH 39/45] docs(config): say min_duration_ms when min_duration_ms is meant Every bare 'duration' the MR added is now explicit: the sizing input is min_duration_ms (poisson-only, target_qps x min_duration_ms, sizing not a timer), the runtime cap is max_duration_ms, and the give-up deadline is run_timeout_s. total_samples_to_issue's priority docstring names the actual rule per step; offline/concurrency runs are called out as purely count-driven (min_duration_ms with them is a config error). --- docs/CLI_QUICK_REFERENCE.md | 10 +++++---- docs/compliance_audit_plan.md | 4 ++-- docs/config/DESIGN.md | 22 +++++++++---------- .../offline_llama3_8b_cnn.yaml | 2 +- .../online_llama2_70b_cnn.yaml | 2 +- .../gptoss_120b_example.yaml | 2 +- .../sglang_gptoss_120b_example.yaml | 2 +- .../vllm_gptoss_120b_example.yaml | 2 +- .../online_llama2_70b_orca.yaml | 2 +- ...ractive_qwen3_vl_235b_a22b_shopify_8k.yaml | 2 +- .../offline_qwen3_vl_235b_a22b_shopify.yaml | 2 +- .../server_qwen3_vl_235b_a22b_shopify.yaml | 2 +- .../offline_wan22_submission.yaml | 2 +- .../single_stream_wan22_submission.yaml | 2 +- .../config/runtime_settings.py | 14 ++++++------ src/inference_endpoint/config/schema.py | 14 +++++++----- .../templates/concurrency_template_full.yaml | 4 ++-- .../templates/offline_template_full.yaml | 4 ++-- .../templates/online_template_full.yaml | 4 ++-- 19 files changed, 52 insertions(+), 46 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index 450267072..b1b0d825e 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -118,8 +118,8 @@ Flag names shown as `--full.dotted.path --alias`. Both forms work. ## Time Knobs -All give-up deadlines live under `settings.timeouts`; the only workload duration is -`settings.runtime.max_duration_ms`; endpoint-client worker lifecycle timeouts are client +All give-up deadlines live under `settings.timeouts`; the only runtime workload cap is +`settings.runtime.max_duration_ms` (`min_duration_ms` is a sizing input, not a timer); endpoint-client worker lifecycle timeouts are client internals under `settings.client`. `null`/unset means "wait indefinitely" (or "off") everywhere. Where every knob acts over the life of a run: @@ -176,10 +176,12 @@ run_benchmark ── run_timeout_s deadline captured here ─────── How the knobs compose: -1. **`--num-samples` / duration / dataset-once defines the work.** An explicit +1. **`--num-samples` / `min_duration_ms` / dataset-once defines the work.** An explicit `runtime.n_samples_to_issue` sets the sample count; otherwise `runtime.min_duration_ms` - (poisson only) derives it as QPS x duration; with neither set, the performance dataset is + (poisson only) derives it as target_qps x min_duration_ms; with neither set, the performance dataset is issued once. + Offline (`max_throughput`) and `concurrency` runs are purely count-driven: `min_duration_ms` + with those patterns is a config error (`max_duration_ms` still caps issuing for every pattern). 2. **`runtime.max_duration_ms` caps performance-phase issuing** and ends the phase normally — remaining samples are not issued, in-flight requests are abandoned (no drain), the report is valid. It does not bound the drain: issuing and draining are consecutive, never concurrent. diff --git a/docs/compliance_audit_plan.md b/docs/compliance_audit_plan.md index fa24f09cc..32ec54769 100644 --- a/docs/compliance_audit_plan.md +++ b/docs/compliance_audit_plan.md @@ -523,11 +523,11 @@ Two scenarios must be covered: **Offline** (`max_throughput`) and **SingleStream > catches a crashed run — but the examples default to equal for the clearest, least-contentious > comparison. -> **No duration floor (current limitation).** Runs are count-driven: the load-generator stop +> **No min_duration_ms floor (current limitation).** Runs are count-driven: the load-generator stop > check (`session.py`) halts a phase on **sample count** or **`runtime.max_duration_ms`** > only, and TEST04 drives explicit `samples` / `audit_samples` counts. MLCommons' 10-minute > compliance minimum therefore is **not** enforced today; combining a count floor with a -> duration floor ("AND-semantics") is future work. Set `samples` large enough that each phase +> min_duration_ms floor ("AND-semantics") is future work. Set `samples` large enough that each phase > reaches a stable throughput on its own. Both scenarios ship as committed configs (see also diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index 8c65a33af..87aa01108 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -58,17 +58,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | -| `min_duration_ms` | `int \| None` | `--runtime.min-duration-ms` / `runtime.min_duration_ms` (poisson only; None = no duration target); a ruleset may override once ruleset integration lands | -| `max_duration_ms` | `int \| None` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | +| `min_duration_ms` | `int \| None` | `--runtime.min-duration-ms` / `runtime.min_duration_ms` (poisson only; None = no min_duration_ms target); a ruleset may override once ruleset integration lands | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml b/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml index 5420d4521..03c4768be 100644 --- a/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml +++ b/examples/02_ServerBenchmarking/offline_llama3_8b_cnn.yaml @@ -20,7 +20,7 @@ datasets: settings: runtime: max_duration_ms: 60000 # 1 minute cap on the performance phase - n_samples_to_issue: 1000 # ≈ ceil(10 QPS × 6 s × 1.1) rounded up to the 1000-sample dataset (replaces the duration-derived count) + n_samples_to_issue: 1000 # ≈ ceil(10 QPS × 6 s × 1.1) rounded up to the 1000-sample dataset (replaces the min_duration_ms-derived count) scheduler_random_seed: 137 # For Poisson/distribution sampling dataloader_random_seed: 111 # For dataset shuffling diff --git a/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml b/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml index 3dac9b4bb..77329b71b 100644 --- a/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml +++ b/examples/02_ServerBenchmarking/online_llama2_70b_cnn.yaml @@ -20,7 +20,7 @@ datasets: settings: runtime: max_duration_ms: 180000 # 3 minute cap on the performance phase - n_samples_to_issue: 1000 # ≈ ceil(10 QPS × 60 s × 1.1) = 660 rounded up to the 1000-sample dataset (replaces the duration-derived count) + n_samples_to_issue: 1000 # ≈ ceil(10 QPS × 60 s × 1.1) = 660 rounded up to the 1000-sample dataset (replaces the min_duration_ms-derived count) scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling diff --git a/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml b/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml index cbaeea2b3..8ac30b8eb 100644 --- a/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml +++ b/examples/04_GPTOSS120B_Example/gptoss_120b_example.yaml @@ -18,7 +18,7 @@ datasets: settings: runtime: max_duration_ms: 6000 # 6 s cap on the performance phase (short smoke run) - # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml b/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml index 650a95648..5208bf0bc 100644 --- a/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml +++ b/examples/04_GPTOSS120B_Example/sglang_gptoss_120b_example.yaml @@ -39,7 +39,7 @@ datasets: settings: runtime: max_duration_ms: 60000 # 1 minute cap on the performance phase - # Sample count: perf dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: perf dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml index e4a19063c..73a9f4bd4 100644 --- a/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml +++ b/examples/04_GPTOSS120B_Example/vllm_gptoss_120b_example.yaml @@ -42,7 +42,7 @@ datasets: settings: runtime: max_duration_ms: 60000 # 1 minute cap on the performance phase - # Sample count: perf dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: perf dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/05_Llama_Examples/online_llama2_70b_orca.yaml b/examples/05_Llama_Examples/online_llama2_70b_orca.yaml index 3c227111d..3a2ac3b22 100644 --- a/examples/05_Llama_Examples/online_llama2_70b_orca.yaml +++ b/examples/05_Llama_Examples/online_llama2_70b_orca.yaml @@ -23,7 +23,7 @@ datasets: settings: runtime: max_duration_ms: 600000 # 10 minute cap on the performance phase - # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml index a41425503..60aef353c 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/interactive_qwen3_vl_235b_a22b_shopify_8k.yaml @@ -22,7 +22,7 @@ datasets: settings: runtime: - # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml index 1f02ea16f..890a5ab85 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/offline_qwen3_vl_235b_a22b_shopify.yaml @@ -21,7 +21,7 @@ datasets: settings: runtime: - # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 # For Poisson/distribution sampling dataloader_random_seed: 42 # For dataset shuffling diff --git a/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml b/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml index 9b835b249..874d13ea3 100644 --- a/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml +++ b/examples/08_Qwen3-VL-235B-A22B_Example/server_qwen3_vl_235b_a22b_shopify.yaml @@ -21,7 +21,7 @@ datasets: settings: runtime: - # Sample count: dataset issued once (the default) — replaces the old duration-derived count, which rounded up to one dataset pass. + # Sample count: dataset issued once (the default) — replaces the old min_duration_ms-derived count, which rounded up to one dataset pass. scheduler_random_seed: 42 dataloader_random_seed: 42 diff --git a/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml b/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml index fd7ed1afa..c833256db 100644 --- a/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml +++ b/examples/09_Wan22_VideoGen_Example/offline_wan22_submission.yaml @@ -50,7 +50,7 @@ audit: settings: runtime: - # NOTE: runs are count-driven (n_samples_to_issue / audit.samples); there is no duration + # NOTE: runs are count-driven (n_samples_to_issue / audit.samples); there is no min_duration_ms # floor — MLCommons' 10-min minimum / AND-semantics is future work. max_duration_ms only # caps the performance phase. max_duration_ms: 14400000 # 4-hour ceiling diff --git a/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml b/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml index de4c534e5..e21532145 100644 --- a/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml +++ b/examples/09_Wan22_VideoGen_Example/single_stream_wan22_submission.yaml @@ -50,7 +50,7 @@ audit: settings: runtime: - # NOTE: runs are count-driven (n_samples_to_issue / audit counts); there is no duration + # NOTE: runs are count-driven (n_samples_to_issue / audit counts); there is no min_duration_ms # floor — MLCommons' 10-min minimum / AND-semantics is future work. max_duration_ms only # caps the performance phase. max_duration_ms: 7200000 # 2-hour ceiling diff --git a/src/inference_endpoint/config/runtime_settings.py b/src/inference_endpoint/config/runtime_settings.py index 0e07a868f..c6aef2e49 100644 --- a/src/inference_endpoint/config/runtime_settings.py +++ b/src/inference_endpoint/config/runtime_settings.py @@ -116,9 +116,9 @@ class RuntimeSettings: """Load pattern configuration""" min_duration_ms: int | None = field(default=None, kw_only=True) - """Minimum performance-phase duration in ms (None/0 = no duration target: - issue the dataset once). Only rulesets set this; the config surface has no - duration-derived sample count.""" + """Sizing input, not a timer: n_samples_to_issue is derived as + target_qps x min_duration_ms when set (None/0 = no min_duration_ms + target: issue the dataset once). Only rulesets set this.""" sample_order: SampleOrderSpec = field(default_factory=SampleOrderSpec, kw_only=True) """Sample-ordering strategy (default: without-replacement).""" @@ -212,8 +212,8 @@ def total_samples_to_issue( Priority: 1. If `n_samples_to_issue` is set, return it (explicit override) - 2. If no duration target is set, return all dataset samples - 3. Otherwise, calculate from metric target * duration + 2. If no min_duration_ms target is set, return all dataset samples + 3. Otherwise, derive the count as target_qps x min_duration_ms Args: padding_factor (float): Factor to multiply the expected number of samples by to account for variance. @@ -250,12 +250,12 @@ def total_samples_to_issue( ) return self.n_samples_from_dataset - # No duration target (None from config, 0 from programmatic callers): + # No min_duration_ms target (None from config, 0 from programmatic callers): # issue the dataset once. if not self.min_duration_ms: result = max(self.min_sample_count, self.n_samples_from_dataset) logger.debug( - f"Sample count: {result} (using all dataset samples, no duration target)" + f"Sample count: {result} (all dataset samples; no min_duration_ms target)" ) return result diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index 7fd6f2a4c..de83d6beb 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -613,7 +613,7 @@ class RuntimeConfig(BaseModel): help=( "POISSON MODE ONLY (requires an explicit target_qps; rejected " "for offline/max_throughput and concurrency runs). Size the " - "run by time: issue target_qps × this duration worth of " + "run by time: derive the sample count as target_qps × " "samples (ms, or suffix: 600s, 10m). Precedence: an explicit " "--num-samples always wins; unset, this derivation applies; " "both unset = issue the dataset once" @@ -623,16 +623,20 @@ class RuntimeConfig(BaseModel): None, gt=0, description=( - "Minimum test duration in ms (poisson only; requires explicit " - "target_qps): sizes the run as target_qps × duration samples. " - "Overridden by an explicit n_samples_to_issue; None = no duration " + "Sizing input, not a timer (poisson only; requires explicit " + "target_qps): derives the sample count as target_qps × " + "min_duration_ms. Overridden by an explicit n_samples_to_issue; " + "None = no min_duration_ms " "target (issue the dataset once)" ), ) max_duration_ms: int | None = Field( None, gt=0, - description="Maximum test duration in ms (None for no limit)", + description=( + "Cap on performance-phase issuing in ms; reaching it ends the " + "phase normally (None = no cap)" + ), ) @field_validator("min_duration_ms", "max_duration_ms", mode="before") diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 4dfa7ba5a..90fcdd277 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -51,8 +51,8 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: null # Minimum test duration in ms (poisson only; requires explicit target_qps): sizes the run as target_qps × duration samples. Overridden by an explicit n_samples_to_issue; None = no duration target (issue the dataset once) - max_duration_ms: null # Maximum test duration in ms (None for no limit) + min_duration_ms: null # Sizing input, not a timer (poisson only; requires explicit target_qps): derives the sample count as target_qps × min_duration_ms. Overridden by an explicit n_samples_to_issue; None = no min_duration_ms target (issue the dataset once) + max_duration_ms: null # Cap on performance-phase issuing in ms; reaching it ends the phase normally (None = no cap) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed dataloader_random_seed: 42 # Dataloader RNG seed diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index ddaef1168..30fa3ed95 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -51,8 +51,8 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: null # Minimum test duration in ms (poisson only; requires explicit target_qps): sizes the run as target_qps × duration samples. Overridden by an explicit n_samples_to_issue; None = no duration target (issue the dataset once) - max_duration_ms: null # Maximum test duration in ms (None for no limit) + min_duration_ms: null # Sizing input, not a timer (poisson only; requires explicit target_qps): derives the sample count as target_qps × min_duration_ms. Overridden by an explicit n_samples_to_issue; None = no min_duration_ms target (issue the dataset once) + max_duration_ms: null # Cap on performance-phase issuing in ms; reaching it ends the phase normally (None = no cap) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed dataloader_random_seed: 42 # Dataloader RNG seed diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index e9bafe8a9..61785aedf 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -51,8 +51,8 @@ datasets: # Dataset configs generation_config_override: null # Per-dataset overrides for the top-level model_params (sparse — only the fields you want to override). Merged on top of BenchmarkConfig.model_params at dataset-load time. Useful for MLPerf-style runs where accuracy and performance use different output budgets in the same fleet, e.g. generation_config_override: {max_new_tokens: 32768, temperature: 0.0}. NOTE: per-run/identity keys (`name`, `streaming`, `tokenizer_name`) are rejected here — set them on top-level model_params. settings: runtime: - min_duration_ms: null # Minimum test duration in ms (poisson only; requires explicit target_qps): sizes the run as target_qps × duration samples. Overridden by an explicit n_samples_to_issue; None = no duration target (issue the dataset once) - max_duration_ms: null # Maximum test duration in ms (None for no limit) + min_duration_ms: null # Sizing input, not a timer (poisson only; requires explicit target_qps): derives the sample count as target_qps × min_duration_ms. Overridden by an explicit n_samples_to_issue; None = no min_duration_ms target (issue the dataset once) + max_duration_ms: null # Cap on performance-phase issuing in ms; reaching it ends the phase normally (None = no cap) n_samples_to_issue: null # Sample count override scheduler_random_seed: 42 # Scheduler RNG seed dataloader_random_seed: 42 # Dataloader RNG seed From 3e921a0c92fe460de4a59b956d402926ac7505e7 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 18:50:03 -0700 Subject: [PATCH 40/45] rename(config): teardown_grace_s -> interrupted_teardown_grace_s The grace applies exactly to runs being marked INTERRUPTED (^C or the run watchdog); the name now says so. Config field, constructor params, docs table, and templates renamed together. --- docs/CLI_QUICK_REFERENCE.md | 30 +++++++++---------- src/inference_endpoint/commands/audit.py | 2 +- .../commands/benchmark/execute.py | 24 ++++++++------- src/inference_endpoint/config/schema.py | 2 +- .../templates/concurrency_template_full.yaml | 2 +- .../templates/offline_template_full.yaml | 2 +- .../templates/online_template_full.yaml | 2 +- tests/integration/commands/test_sigint.py | 13 ++++---- tests/unit/commands/test_watchdog.py | 18 +++++------ tests/unit/config/test_timeouts.py | 8 ++--- 10 files changed, 54 insertions(+), 49 deletions(-) diff --git a/docs/CLI_QUICK_REFERENCE.md b/docs/CLI_QUICK_REFERENCE.md index b1b0d825e..1552ffc21 100644 --- a/docs/CLI_QUICK_REFERENCE.md +++ b/docs/CLI_QUICK_REFERENCE.md @@ -159,20 +159,20 @@ run_benchmark ── run_timeout_s deadline captured here ─────── deadline-bounded) ``` -| YAML path | CLI flag | Semantics | -| ----------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `settings.runtime.min_duration_ms` | `--runtime.min-duration-ms` | Poisson only (requires explicit `target_qps`). Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`). Explicit `--num-samples` wins; both unset = issue the dataset once | -| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | -| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | -| `settings.timeouts.service_ready_timeout_s` | `--settings.timeouts.service-ready-timeout-s` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | -| `settings.timeouts.warmup_drain_timeout_s` | `--settings.timeouts.warmup-drain-timeout-s` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | -| `settings.timeouts.performance_drain_timeout_s` | `--settings.timeouts.performance-drain-timeout-s` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | -| `settings.timeouts.accuracy_drain_timeout_s` | `--settings.timeouts.accuracy-drain-timeout-s` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | -| `settings.timeouts.metrics_drain_timeout_s` | `--settings.timeouts.metrics-drain-timeout-s` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | -| `settings.timeouts.teardown_grace_s` | `--settings.timeouts.teardown-grace-s` | Grace after an abort (^C or `run_timeout_s`) before a still-running metrics drain is abandoned, SIGTERM→SIGKILL (default 30; null = never abandon) | -| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | -| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | -| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | +| YAML path | CLI flag | Semantics | +| ------------------------------------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `settings.runtime.min_duration_ms` | `--runtime.min-duration-ms` | Poisson only (requires explicit `target_qps`). Sizes the run by time: issue `target_qps` × duration samples (ms, or suffix: `600s`, `10m`). Explicit `--num-samples` wins; both unset = issue the dataset once | +| `settings.runtime.max_duration_ms` | `--runtime.max-duration-ms` | Caps performance-phase issuing (ms, or suffix: `600s`, `10m`); reaching it ends the phase NORMALLY — the report stays valid — and skips the performance drain | +| `settings.timeouts.run_timeout_s` | `--timeout` | Whole-run watchdog from setup through worker shutdown; firing aborts the run — report marked INTERRUPTED, non-zero exit. Finalization (accuracy scoring, artifact writes) runs after the watchdog and is not deadline-bounded | +| `settings.timeouts.service_ready_timeout_s` | `--settings.timeouts.service-ready-timeout-s` | Wait for the metrics-aggregator/event-logger services to become ready (default 30) | +| `settings.timeouts.warmup_drain_timeout_s` | `--settings.timeouts.warmup-drain-timeout-s` | Bound on in-flight warmup requests after the warmup phase ends (default 240) | +| `settings.timeouts.performance_drain_timeout_s` | `--settings.timeouts.performance-drain-timeout-s` | Bound on in-flight performance requests after the phase stops issuing (default: wait indefinitely) | +| `settings.timeouts.accuracy_drain_timeout_s` | `--settings.timeouts.accuracy-drain-timeout-s` | Bound on in-flight accuracy requests after the phase ends (default: wait indefinitely) | +| `settings.timeouts.metrics_drain_timeout_s` | `--settings.timeouts.metrics-drain-timeout-s` | Budget for the metrics aggregator to finish tokenizing buffered samples after the run ends (default: wait indefinitely); expiring fails the run with `complete: false` artifacts | +| `settings.timeouts.interrupted_teardown_grace_s` | `--settings.timeouts.interrupted-teardown-grace-s` | Grace after an abort (^C or `run_timeout_s`) before a still-running metrics drain is abandoned, SIGTERM→SIGKILL (default 30; null = never abandon) | +| `settings.client.worker_initialization_timeout` | `--client.worker-initialization-timeout` | Wait for endpoint-client worker processes to start (default 60) | +| `settings.client.worker_graceful_shutdown_wait` | `--client.worker-graceful-shutdown-wait` | Post-run wait for workers to exit gracefully (default 0.5) | +| `settings.client.worker_force_kill_timeout` | `--client.worker-force-kill-timeout` | Wait after the graceful window before force-killing workers (default 0.5) | How the knobs compose: @@ -198,7 +198,7 @@ One handler owns SIGINT for the whole run, with one behavior: drains are released, buffered samples still reach the metrics aggregator, and the artifacts land honest — `result_summary.json` `state: interrupted`, `complete: false`, `events.jsonl` flushed. Exit 130. Teardown is bounded: - if the metrics drain has not finished within `timeouts.teardown_grace_s` + if the metrics drain has not finished within `timeouts.interrupted_teardown_grace_s` (default 30 s; null = never abandon) of the ^C, the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed — a wedged drain can never hang the abort. diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index 1ed8c70f3..3e2b9dba7 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -81,7 +81,7 @@ def run_audit(config: BenchmarkConfig, base_report_dir: Path) -> AuditResult: # One SIGINT policy for the whole audit (same pattern as run_benchmark): # first ^C stops the current phase gracefully, which surfaces as # report.state=="interrupted" and aborts the audit. - sigint = SigintGovernor(config.settings.timeouts.teardown_grace_s) + sigint = SigintGovernor(config.settings.timeouts.interrupted_teardown_grace_s) with sigint_policy(sigint): artifacts = _run_phases(config, base_report_dir, test, audit_cfg, specs, sigint) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 5ea03d61a..64a4d7411 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -729,9 +729,9 @@ class SigintGovernor: is a logged no-op. """ - def __init__(self, teardown_grace_s: float | None) -> None: + def __init__(self, interrupted_teardown_grace_s: float | None) -> None: self.interrupted = False - self._teardown_grace_s = teardown_grace_s + self._interrupted_teardown_grace_s = interrupted_teardown_grace_s self._session: BenchmarkSession | None = None self._task: asyncio.Task | None = None self._loop: asyncio.AbstractEventLoop | None = None @@ -763,7 +763,7 @@ def _stop_gracefully(self) -> None: self._session.stop() if ( self._on_grace_expiry is not None - and self._teardown_grace_s is not None + and self._interrupted_teardown_grace_s is not None and self._grace_handle is None ): @@ -771,12 +771,14 @@ def _expire() -> None: logger.warning( "Teardown did not finish within %.0fs of ^C — abandoning " "the metrics drain", - self._teardown_grace_s, + self._interrupted_teardown_grace_s, ) assert self._on_grace_expiry is not None self._on_grace_expiry() - self._grace_handle = self._loop.call_later(self._teardown_grace_s, _expire) + self._grace_handle = self._loop.call_later( + self._interrupted_teardown_grace_s, _expire + ) def __call__(self, signum: int, frame: types.FrameType | None) -> None: if self.interrupted: @@ -842,14 +844,14 @@ def __init__( loop: asyncio.AbstractEventLoop, deadline: float | None, pipe: MetricsPipeline, - teardown_grace_s: float | None, + interrupted_teardown_grace_s: float | None, ) -> None: self.fired = False self._session: BenchmarkSession | None = None self._task: asyncio.Task | None = None self._pipe = pipe self._loop = loop - self._teardown_grace_s = teardown_grace_s + self._interrupted_teardown_grace_s = interrupted_teardown_grace_s self._escalation: asyncio.TimerHandle | None = None self._handle = ( loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) @@ -885,11 +887,11 @@ def _fire(self) -> None: return self._session.stop() self._pipe.terminate_metrics_aggregator() - if self._teardown_grace_s is not None: + if self._interrupted_teardown_grace_s is not None: # A wedged aggregator ignores the SIGTERM; escalate so the # deadline stays a hard bound (cancelled when the drain finishes). self._escalation = self._loop.call_later( - self._teardown_grace_s, self._pipe.abandon_drain + self._interrupted_teardown_grace_s, self._pipe.abandon_drain ) def cancel(self) -> None: @@ -1035,7 +1037,7 @@ async def _run_benchmark_async( http_client: HTTPEndpointClient | None = None watchdog = RunWatchdog( - loop, deadline, pipe, config.settings.timeouts.teardown_grace_s + loop, deadline, pipe, config.settings.timeouts.interrupted_teardown_grace_s ) watchdog.bind_task(asyncio.current_task()) if sigint is not None: @@ -1495,7 +1497,7 @@ def run_benchmark( # The run's ONE SIGINT handler; sigint_policy restores the previous one # only after the finally below, so a repeat ^C during salvage still hits # the governor's no-op. - sigint = SigintGovernor(config.settings.timeouts.teardown_grace_s) + sigint = SigintGovernor(config.settings.timeouts.interrupted_teardown_grace_s) bench: BenchmarkResult | None = None with sigint_policy(sigint): try: diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index de83d6beb..a9e762829 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -848,7 +848,7 @@ class Timeouts(WithUpdatesMixin, BaseModel): "INTERRUPTED, and exits non-zero. Never derives per-stage deadlines." ), ) - teardown_grace_s: float | None = Field( + interrupted_teardown_grace_s: float | None = Field( 30.0, ge=0, description=( diff --git a/src/inference_endpoint/config/templates/concurrency_template_full.yaml b/src/inference_endpoint/config/templates/concurrency_template_full.yaml index 90fcdd277..9103023f5 100644 --- a/src/inference_endpoint/config/templates/concurrency_template_full.yaml +++ b/src/inference_endpoint/config/templates/concurrency_template_full.yaml @@ -86,7 +86,7 @@ settings: worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. - teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). + interrupted_teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/config/templates/offline_template_full.yaml b/src/inference_endpoint/config/templates/offline_template_full.yaml index 30fa3ed95..3abc6591e 100644 --- a/src/inference_endpoint/config/templates/offline_template_full.yaml +++ b/src/inference_endpoint/config/templates/offline_template_full.yaml @@ -86,7 +86,7 @@ settings: worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. - teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). + interrupted_teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/src/inference_endpoint/config/templates/online_template_full.yaml b/src/inference_endpoint/config/templates/online_template_full.yaml index 61785aedf..4c40fa48e 100644 --- a/src/inference_endpoint/config/templates/online_template_full.yaml +++ b/src/inference_endpoint/config/templates/online_template_full.yaml @@ -87,7 +87,7 @@ settings: worker_gc_mode: relaxed # Worker GC strategy | options: disabled, relaxed, system timeouts: # All global waits and deadlines (see config/schema.py) run_timeout_s: null # Whole-run watchdog in seconds (None = off). Bounds the run from service launch through every phase and drain; synchronous setup (tokenizer probe, dataset load) counts against the budget but is only checked at its boundary — a hung setup call itself is not interrupted. Firing aborts the run, marks the report INTERRUPTED, and exits non-zero. Never derives per-stage deadlines. - teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). + interrupted_teardown_grace_s: 30.0 # Seconds after an abort (^C or a run_timeout_s fire) before a still-running metrics drain is abandoned: the service children are SIGTERMed (the aggregator writes a best-effort INTERRUPTED snapshot) then SIGKILLed, so a wedged drain can never hang the abort (None = never abandon; 0 = abandon immediately). service_ready_timeout_s: 30.0 # Seconds to wait for metrics-aggregator/event-logger services to become ready. warmup_drain_timeout_s: 240.0 # Warmup drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) performance_drain_timeout_s: null # Performance drain timeout in seconds (None = wait indefinitely; 0 = skip the drain) diff --git a/tests/integration/commands/test_sigint.py b/tests/integration/commands/test_sigint.py index 1f05fceae..07f3b3517 100644 --- a/tests/integration/commands/test_sigint.py +++ b/tests/integration/commands/test_sigint.py @@ -60,12 +60,12 @@ def _write_config( report_dir: Path, endpoint_url: str, config_path: Path, - teardown_grace_s: float | None = None, + interrupted_teardown_grace_s: float | None = None, ) -> None: """~120 s workload (600 samples @ 5 QPS): only the ^C can end the run.""" grace = ( - f"\n timeouts:\n teardown_grace_s: {teardown_grace_s}" - if teardown_grace_s is not None + f"\n timeouts:\n interrupted_teardown_grace_s: {interrupted_teardown_grace_s}" + if interrupted_teardown_grace_s is not None else "" ) config_path.write_text( @@ -228,13 +228,16 @@ def test_sigint_grace_expiry_abandons_wedged_drain(mock_http_echo_server, tmp_pa (``os.kill``, not the group): the graceful stop parks on the wedged drain; grace expiry must SIGTERM→SIGKILL the children so the drain's wait-for-exit unblocks and the run exits 130 without a second keystroke. - The grace is shrunk to 3s (settings.timeouts.teardown_grace_s; default + The grace is shrunk to 3s (settings.timeouts.interrupted_teardown_grace_s; default 30) so the test stays fast. """ report_dir = tmp_path / "report" config_path = tmp_path / "bench.yaml" _write_config( - report_dir, mock_http_echo_server.url, config_path, teardown_grace_s=3.0 + report_dir, + mock_http_echo_server.url, + config_path, + interrupted_teardown_grace_s=3.0, ) agg_pid: int | None = None diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index c46eea1bc..a08c2993e 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -37,7 +37,7 @@ def _fire(gov: SigintGovernor) -> None: @pytest.mark.unit class TestSigintGovernor: def test_unbound_sigint_raises_keyboard_interrupt(self): - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) with pytest.raises(KeyboardInterrupt): _fire(gov) assert gov.interrupted @@ -49,7 +49,7 @@ def test_sigint_after_loop_returned_raises_immediately(self): bound but the loop is stopped — ``call_soon_threadsafe`` would queue ``session.stop`` on it and never run it. """ - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) session = MagicMock() async def run_phase() -> None: @@ -65,7 +65,7 @@ async def run_phase() -> None: @pytest.mark.asyncio async def test_live_sigint_stops_session_and_arms_grace(self): - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) session = MagicMock() on_grace = MagicMock() gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) @@ -82,7 +82,7 @@ async def test_live_sigint_stops_session_and_arms_grace(self): @pytest.mark.asyncio async def test_repeat_sigint_is_a_noop(self): """Any repeat ^C (incl. a forwarded duplicate under `uv run`) is silent.""" - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) session = MagicMock() gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) gov.bind_session(session, MagicMock()) @@ -97,7 +97,7 @@ async def test_repeat_sigint_is_a_noop(self): @pytest.mark.asyncio async def test_grace_expiry_fires_callback_once(self): """The grace fires exactly once, even after repeat ^C deliveries.""" - gov = SigintGovernor(teardown_grace_s=0.02) + gov = SigintGovernor(interrupted_teardown_grace_s=0.02) session = MagicMock() fired = asyncio.Event() on_grace = MagicMock(side_effect=fired.set) @@ -113,7 +113,7 @@ async def test_grace_expiry_fires_callback_once(self): @pytest.mark.asyncio async def test_cancel_grace_disarms_pending_timer(self): - gov = SigintGovernor(teardown_grace_s=0.02) + gov = SigintGovernor(interrupted_teardown_grace_s=0.02) session = MagicMock() on_grace = MagicMock() gov.bind_task(asyncio.current_task(), asyncio.get_running_loop()) @@ -130,14 +130,14 @@ async def test_cancel_grace_disarms_pending_timer(self): @pytest.mark.unit class TestSigintPolicy: def test_installs_and_restores_previous_handler(self): - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) prev = signal.getsignal(signal.SIGINT) with sigint_policy(gov): assert signal.getsignal(signal.SIGINT) is gov assert signal.getsignal(signal.SIGINT) is prev def test_restores_on_exception(self): - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) prev = signal.getsignal(signal.SIGINT) with pytest.raises(RuntimeError): with sigint_policy(gov): @@ -146,7 +146,7 @@ def test_restores_on_exception(self): def test_unrepresentable_c_handler_stays_untouched(self, monkeypatch): """getsignal()->None (C-installed handler): install nothing at all.""" - gov = SigintGovernor(teardown_grace_s=30.0) + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) monkeypatch.setattr(signal, "getsignal", lambda signum: None) install_spy = MagicMock() monkeypatch.setattr(signal, "signal", install_spy) diff --git a/tests/unit/config/test_timeouts.py b/tests/unit/config/test_timeouts.py index 1c7256efd..3cfd7b3de 100644 --- a/tests/unit/config/test_timeouts.py +++ b/tests/unit/config/test_timeouts.py @@ -48,7 +48,7 @@ def test_defaults(self): assert cfg.run_timeout_s is None assert cfg.service_ready_timeout_s == 30.0 assert cfg.warmup_drain_timeout_s == 240.0 - assert cfg.teardown_grace_s == 30.0 + assert cfg.interrupted_teardown_grace_s == 30.0 assert cfg.performance_drain_timeout_s is None assert cfg.accuracy_drain_timeout_s is None assert cfg.metrics_drain_timeout_s is None @@ -75,7 +75,7 @@ class TestTimeoutsValidation: ("run_timeout_s", 0), ("run_timeout_s", -1.0), ("warmup_drain_timeout_s", -1.0), - ("teardown_grace_s", -1.0), + ("interrupted_teardown_grace_s", -1.0), ("performance_drain_timeout_s", -1.0), ("accuracy_drain_timeout_s", -1.0), ("metrics_drain_timeout_s", -1.0), @@ -96,7 +96,7 @@ def test_deadline_must_be_positive_or_none(self, field, value): "performance_drain_timeout_s", "accuracy_drain_timeout_s", "metrics_drain_timeout_s", - "teardown_grace_s", + "interrupted_teardown_grace_s", ], ) def test_zero_drain_budget_is_valid(self, field): @@ -112,7 +112,7 @@ def test_zero_drain_budget_is_valid(self, field): "performance_drain_timeout_s", "accuracy_drain_timeout_s", "metrics_drain_timeout_s", - "teardown_grace_s", + "interrupted_teardown_grace_s", ], ) def test_deadline_none_means_unlimited(self, field): From 6ce3efa08a931a0b3186a8e7733d86795675ce6d Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 18:52:43 -0700 Subject: [PATCH 41/45] docs(examples): comment every max_duration_ms cap value --- examples/09_Wan22_VideoGen_Example/offline_wan22_accuracy.yaml | 2 +- examples/11_Edge_Agentic_Example/online_edge_full_run.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/09_Wan22_VideoGen_Example/offline_wan22_accuracy.yaml b/examples/09_Wan22_VideoGen_Example/offline_wan22_accuracy.yaml index b3a4dd50e..4f4d500b7 100644 --- a/examples/09_Wan22_VideoGen_Example/offline_wan22_accuracy.yaml +++ b/examples/09_Wan22_VideoGen_Example/offline_wan22_accuracy.yaml @@ -51,7 +51,7 @@ datasets: settings: runtime: - max_duration_ms: 600000 + max_duration_ms: 600000 # 10 minute cap on the performance phase scheduler_random_seed: 42 dataloader_random_seed: 42 n_samples_to_issue: 248 diff --git a/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml b/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml index b91ab2ac4..2eb20ad88 100644 --- a/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml +++ b/examples/11_Edge_Agentic_Example/online_edge_full_run.yaml @@ -91,7 +91,7 @@ settings: runtime: # Safety cap (4 h) so the performance phase stays bounded even if decode is # slower than expected; one pass should finish in ~2.5 h on an edge box. - max_duration_ms: 14400000 + max_duration_ms: 14400000 # 4-hour cap on the performance phase dataloader_random_seed: 42 # BFCL accuracy sampling seed; compliance requires 42. load_pattern: # Drives the performance phase; the accuracy phase self-overrides to From 0f6b0e4daa7f019aff70d859e9b6675146d60bc1 Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 19:01:16 -0700 Subject: [PATCH 42/45] refactor: SigintGovernor/RunWatchdog back in watchdog.py; precise min_duration_ms docstring The interrupt machinery (SigintGovernor, sigint_policy, RunWatchdog) returns to commands/benchmark/watchdog.py - the module reviewers reviewed; the perf-phase cap (_PerfPhaseTimeout) stays at its original execute.py location. RuntimeSettings.min_duration_ms now documents the full contract: sizing input (target_qps x min_duration_ms), never a runtime timer, n_samples_to_issue wins, populated from settings.runtime.min_duration_ms or directly by rulesets. Reverts the gratuitous 'duration floor' -> 'min_duration_ms floor' renames in the compliance docs (the original text was already precise). --- docs/compliance_audit_plan.md | 4 +- docs/config/DESIGN.md | 22 +- src/inference_endpoint/commands/audit.py | 3 +- .../commands/benchmark/execute.py | 197 +-------------- .../commands/benchmark/watchdog.py | 225 ++++++++++++++++++ .../config/runtime_settings.py | 11 +- tests/unit/commands/test_watchdog.py | 4 +- 7 files changed, 255 insertions(+), 211 deletions(-) create mode 100644 src/inference_endpoint/commands/benchmark/watchdog.py diff --git a/docs/compliance_audit_plan.md b/docs/compliance_audit_plan.md index 32ec54769..fa24f09cc 100644 --- a/docs/compliance_audit_plan.md +++ b/docs/compliance_audit_plan.md @@ -523,11 +523,11 @@ Two scenarios must be covered: **Offline** (`max_throughput`) and **SingleStream > catches a crashed run — but the examples default to equal for the clearest, least-contentious > comparison. -> **No min_duration_ms floor (current limitation).** Runs are count-driven: the load-generator stop +> **No duration floor (current limitation).** Runs are count-driven: the load-generator stop > check (`session.py`) halts a phase on **sample count** or **`runtime.max_duration_ms`** > only, and TEST04 drives explicit `samples` / `audit_samples` counts. MLCommons' 10-minute > compliance minimum therefore is **not** enforced today; combining a count floor with a -> min_duration_ms floor ("AND-semantics") is future work. Set `samples` large enough that each phase +> duration floor ("AND-semantics") is future work. Set `samples` large enough that each phase > reaches a stable throughput on its own. Both scenarios ship as committed configs (see also diff --git a/docs/config/DESIGN.md b/docs/config/DESIGN.md index 87aa01108..8c65a33af 100644 --- a/docs/config/DESIGN.md +++ b/docs/config/DESIGN.md @@ -58,17 +58,17 @@ Key nested models: Immutable snapshot of all parameters needed to execute a run. -| Field | Type | Source | -| -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `load_pattern` | `LoadPattern` | config | -| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | -| `min_duration_ms` | `int \| None` | `--runtime.min-duration-ms` / `runtime.min_duration_ms` (poisson only; None = no min_duration_ms target); a ruleset may override once ruleset integration lands | -| `max_duration_ms` | `int \| None` | runtime config | -| `min_sample_count` | `int` | current default / future ruleset hook | -| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | -| `reported_metrics` | `list[Metric]` | metrics validated after the run | -| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | -| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | +| Field | Type | Source | +| -------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `load_pattern` | `LoadPattern` | config | +| `n_samples_to_issue` | `int \| None` | explicit (`--num-samples`), else `target_qps` × `min_duration_ms` (padded) when a min duration is set, else dataset size | +| `min_duration_ms` | `int \| None` | `--runtime.min-duration-ms` / `runtime.min_duration_ms` (poisson only; None = no duration target); a ruleset may override once ruleset integration lands | +| `max_duration_ms` | `int \| None` | runtime config | +| `min_sample_count` | `int` | current default / future ruleset hook | +| `metric_target` | `Metric \| None` | `Throughput(target_qps)` when set; no synthetic default | +| `reported_metrics` | `list[Metric]` | metrics validated after the run | +| `rng_sched` | `Random` | seeded from `scheduler_random_seed` | +| `rng_sample_index` | `Random` | seeded from `dataloader_random_seed` | Once constructed, `RuntimeSettings` cannot be modified. All consumers receive the same instance. diff --git a/src/inference_endpoint/commands/audit.py b/src/inference_endpoint/commands/audit.py index 3e2b9dba7..287c58e63 100644 --- a/src/inference_endpoint/commands/audit.py +++ b/src/inference_endpoint/commands/audit.py @@ -38,14 +38,13 @@ from ..exceptions import ExecutionError, SetupError from .benchmark.execute import ( BenchmarkResult, - SigintGovernor, TestMode, _salvage_tmpfs, finalize_benchmark, run_benchmark_async, setup_benchmark, - sigint_policy, ) +from .benchmark.watchdog import SigintGovernor, sigint_policy logger = logging.getLogger(__name__) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 64a4d7411..99b78e872 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -28,17 +28,14 @@ from __future__ import annotations import asyncio -import contextlib import json import logging import random import shutil -import signal import tempfile import time -import types import uuid -from collections.abc import Callable, Iterator +from collections.abc import Callable from dataclasses import dataclass, field from dataclasses import replace as dataclass_replace from datetime import datetime @@ -64,6 +61,11 @@ ProfileController, write_profiling_section, ) +from inference_endpoint.commands.benchmark.watchdog import ( + RunWatchdog, + SigintGovernor, + sigint_policy, +) from inference_endpoint.compliance import AuditRunSpec from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( @@ -716,193 +718,6 @@ def cancel(self) -> None: self._handle = None -class SigintGovernor: - """The run's single SIGINT policy — installed ONCE per run. - - One handler covers the whole run (setup, session, drain, finalize); the - window-scoped install/remove pairs it replaces were exactly where a ^C - could slip through as a raw KeyboardInterrupt mid-teardown. Behavior is - keystroke-count-independent (group-SIGINT forwarders like ``uv run`` need - no special handling): no live run -> raise KeyboardInterrupt (exit 130); - live run -> graceful ``session.stop()`` plus a teardown grace timer that - abandons a still-wedged metrics drain (SIGTERM -> SIGKILL); any repeat ^C - is a logged no-op. - """ - - def __init__(self, interrupted_teardown_grace_s: float | None) -> None: - self.interrupted = False - self._interrupted_teardown_grace_s = interrupted_teardown_grace_s - self._session: BenchmarkSession | None = None - self._task: asyncio.Task | None = None - self._loop: asyncio.AbstractEventLoop | None = None - self._on_grace_expiry: Callable[[], None] | None = None - self._grace_handle: asyncio.TimerHandle | None = None - - def bind_task( - self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop - ) -> None: - """Bind the run coroutine's task — the live-run gate for the graceful path.""" - self._task = task - self._loop = loop - - def bind_session( - self, session: BenchmarkSession, on_grace_expiry: Callable[[], None] - ) -> None: - self._session = session - self._on_grace_expiry = on_grace_expiry - - def cancel_grace(self) -> None: - """Disarm the teardown grace timer (drain finished on its own).""" - if self._grace_handle is not None: - self._grace_handle.cancel() - self._grace_handle = None - - def _stop_gracefully(self) -> None: - """Runs on the loop: stop the session and bound the teardown.""" - assert self._session is not None and self._loop is not None - self._session.stop() - if ( - self._on_grace_expiry is not None - and self._interrupted_teardown_grace_s is not None - and self._grace_handle is None - ): - - def _expire() -> None: - logger.warning( - "Teardown did not finish within %.0fs of ^C — abandoning " - "the metrics drain", - self._interrupted_teardown_grace_s, - ) - assert self._on_grace_expiry is not None - self._on_grace_expiry() - - self._grace_handle = self._loop.call_later( - self._interrupted_teardown_grace_s, _expire - ) - - def __call__(self, signum: int, frame: types.FrameType | None) -> None: - if self.interrupted: - # Stop already in flight; the grace timer bounds the teardown. - logger.warning("SIGINT again: shutdown already in progress") - return - self.interrupted = True - if ( - self._session is None - or self._task is None - or self._task.done() - or self._loop is None - or not self._loop.is_running() - ): - # No live run to stop gracefully; call_soon_threadsafe on a - # stopped loop would silently swallow the ^C. - raise KeyboardInterrupt - logger.warning("SIGINT received: stopping benchmark gracefully") - # Signal handlers run at arbitrary bytecode boundaries: hand the stop - # to the loop via its one signal-safe entry point. - self._loop.call_soon_threadsafe(self._stop_gracefully) - - -@contextlib.contextmanager -def sigint_policy(governor: SigintGovernor) -> Iterator[None]: - """Install ``governor`` as the SIGINT handler; restore the previous one. - - Passive when ``getsignal`` returns ``None`` (a C-installed handler that - ``signal.signal`` refuses back) or off the main thread. Restores on exit, - after the caller's finally blocks, so a repeat ^C during cleanup still - hits the governor's no-op. - """ - prev = signal.getsignal(signal.SIGINT) - if prev is None: - yield - return - try: - signal.signal(signal.SIGINT, governor) - except ValueError: - yield - return - try: - yield - finally: - signal.signal(signal.SIGINT, prev) - - -class RunWatchdog: - """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. - - Armed before the pipeline starts and kept armed through the metrics - drain. On fire with a session: stop it (ENDED still flows, the event - logger flushes) and SIGTERM the aggregator, whose handler writes the - INTERRUPTED final snapshot; if the aggregator ignores the SIGTERM, the - teardown grace SIGTERM->SIGKILLs the children so the deadline stays a - hard bound. Before the session exists the orchestration task is - cancelled instead, and ``MetricsPipeline.__aexit__`` kills the services. - ``run_benchmark`` raises whenever ``fired`` is set. - """ - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - deadline: float | None, - pipe: MetricsPipeline, - interrupted_teardown_grace_s: float | None, - ) -> None: - self.fired = False - self._session: BenchmarkSession | None = None - self._task: asyncio.Task | None = None - self._pipe = pipe - self._loop = loop - self._interrupted_teardown_grace_s = interrupted_teardown_grace_s - self._escalation: asyncio.TimerHandle | None = None - self._handle = ( - loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) - if deadline is not None - else None - ) - - def bind_task(self, task: asyncio.Task | None) -> None: - """Bind the orchestration task — the pre-session cancellation target.""" - self._task = task - - def bind_session(self, session: BenchmarkSession) -> None: - """Late-bind the session; a deadline that already fired stops it now. - - The caller still runs the stopped session so STARTED/ENDED flow and - the INTERRUPTED artifacts get written. - """ - self._session = session - if self.fired: - session.stop() - - def _fire(self) -> None: - self.fired = True - logger.error( - "Run timeout reached; aborting run — report will be marked INTERRUPTED." - ) - if self._session is None: - # Still in service launch / endpoint connect: cancel the task so - # those awaits unwind now; _run_benchmark_async translates the - # unwind into the run-timeout ExecutionError. - if self._task is not None: - self._task.cancel() - return - self._session.stop() - self._pipe.terminate_metrics_aggregator() - if self._interrupted_teardown_grace_s is not None: - # A wedged aggregator ignores the SIGTERM; escalate so the - # deadline stays a hard bound (cancelled when the drain finishes). - self._escalation = self._loop.call_later( - self._interrupted_teardown_grace_s, self._pipe.abandon_drain - ) - - def cancel(self) -> None: - if self._handle is not None: - self._handle.cancel() - self._handle = None - if self._escalation is not None: - self._escalation.cancel() - self._escalation = None - - async def _create_issuer( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop ) -> tuple[HttpClientSampleIssuer, HTTPEndpointClient]: diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py new file mode 100644 index 000000000..ddaf86018 --- /dev/null +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -0,0 +1,225 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Run-abort machinery: the run's SIGINT policy and whole-run deadline. + +``SigintGovernor`` (+ ``sigint_policy``) is the one Ctrl-C policy; +``RunWatchdog`` enforces ``settings.timeouts.run_timeout_s``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import signal +import time +import types +from collections.abc import Callable, Iterator +from typing import TYPE_CHECKING + +from inference_endpoint.load_generator.session import BenchmarkSession + +if TYPE_CHECKING: + from inference_endpoint.commands.benchmark.pipeline import MetricsPipeline + +logger = logging.getLogger(__name__) + + +class SigintGovernor: + """The run's single SIGINT policy — installed ONCE per run. + + One handler covers the whole run (setup, session, drain, finalize); the + window-scoped install/remove pairs it replaces were exactly where a ^C + could slip through as a raw KeyboardInterrupt mid-teardown. Behavior is + keystroke-count-independent (group-SIGINT forwarders like ``uv run`` need + no special handling): no live run -> raise KeyboardInterrupt (exit 130); + live run -> graceful ``session.stop()`` plus a teardown grace timer that + abandons a still-wedged metrics drain (SIGTERM -> SIGKILL); any repeat ^C + is a logged no-op. + """ + + def __init__(self, interrupted_teardown_grace_s: float | None) -> None: + self.interrupted = False + self._interrupted_teardown_grace_s = interrupted_teardown_grace_s + self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._on_grace_expiry: Callable[[], None] | None = None + self._grace_handle: asyncio.TimerHandle | None = None + + def bind_task( + self, task: asyncio.Task | None, loop: asyncio.AbstractEventLoop + ) -> None: + """Bind the run coroutine's task — the live-run gate for the graceful path.""" + self._task = task + self._loop = loop + + def bind_session( + self, session: BenchmarkSession, on_grace_expiry: Callable[[], None] + ) -> None: + self._session = session + self._on_grace_expiry = on_grace_expiry + + def cancel_grace(self) -> None: + """Disarm the teardown grace timer (drain finished on its own).""" + if self._grace_handle is not None: + self._grace_handle.cancel() + self._grace_handle = None + + def _stop_gracefully(self) -> None: + """Runs on the loop: stop the session and bound the teardown.""" + assert self._session is not None and self._loop is not None + self._session.stop() + if ( + self._on_grace_expiry is not None + and self._interrupted_teardown_grace_s is not None + and self._grace_handle is None + ): + + def _expire() -> None: + logger.warning( + "Teardown did not finish within %.0fs of ^C — abandoning " + "the metrics drain", + self._interrupted_teardown_grace_s, + ) + assert self._on_grace_expiry is not None + self._on_grace_expiry() + + self._grace_handle = self._loop.call_later( + self._interrupted_teardown_grace_s, _expire + ) + + def __call__(self, signum: int, frame: types.FrameType | None) -> None: + if self.interrupted: + # Stop already in flight; the grace timer bounds the teardown. + logger.warning("SIGINT again: shutdown already in progress") + return + self.interrupted = True + if ( + self._session is None + or self._task is None + or self._task.done() + or self._loop is None + or not self._loop.is_running() + ): + # No live run to stop gracefully; call_soon_threadsafe on a + # stopped loop would silently swallow the ^C. + raise KeyboardInterrupt + logger.warning("SIGINT received: stopping benchmark gracefully") + # Signal handlers run at arbitrary bytecode boundaries: hand the stop + # to the loop via its one signal-safe entry point. + self._loop.call_soon_threadsafe(self._stop_gracefully) + + +@contextlib.contextmanager +def sigint_policy(governor: SigintGovernor) -> Iterator[None]: + """Install ``governor`` as the SIGINT handler; restore the previous one. + + Passive when ``getsignal`` returns ``None`` (a C-installed handler that + ``signal.signal`` refuses back) or off the main thread. Restores on exit, + after the caller's finally blocks, so a repeat ^C during cleanup still + hits the governor's no-op. + """ + prev = signal.getsignal(signal.SIGINT) + if prev is None: + yield + return + try: + signal.signal(signal.SIGINT, governor) + except ValueError: + yield + return + try: + yield + finally: + signal.signal(signal.SIGINT, prev) + + +class RunWatchdog: + """Whole-run deadline timer for ``settings.timeouts.run_timeout_s``. + + Armed before the pipeline starts and kept armed through the metrics + drain. On fire with a session: stop it (ENDED still flows, the event + logger flushes) and SIGTERM the aggregator, whose handler writes the + INTERRUPTED final snapshot; if the aggregator ignores the SIGTERM, the + teardown grace SIGTERM->SIGKILLs the children so the deadline stays a + hard bound. Before the session exists the orchestration task is + cancelled instead, and ``MetricsPipeline.__aexit__`` kills the services. + ``run_benchmark`` raises whenever ``fired`` is set. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + deadline: float | None, + pipe: MetricsPipeline, + interrupted_teardown_grace_s: float | None, + ) -> None: + self.fired = False + self._session: BenchmarkSession | None = None + self._task: asyncio.Task | None = None + self._pipe = pipe + self._loop = loop + self._interrupted_teardown_grace_s = interrupted_teardown_grace_s + self._escalation: asyncio.TimerHandle | None = None + self._handle = ( + loop.call_later(max(0.0, deadline - time.monotonic()), self._fire) + if deadline is not None + else None + ) + + def bind_task(self, task: asyncio.Task | None) -> None: + """Bind the orchestration task — the pre-session cancellation target.""" + self._task = task + + def bind_session(self, session: BenchmarkSession) -> None: + """Late-bind the session; a deadline that already fired stops it now. + + The caller still runs the stopped session so STARTED/ENDED flow and + the INTERRUPTED artifacts get written. + """ + self._session = session + if self.fired: + session.stop() + + def _fire(self) -> None: + self.fired = True + logger.error( + "Run timeout reached; aborting run — report will be marked INTERRUPTED." + ) + if self._session is None: + # Still in service launch / endpoint connect: cancel the task so + # those awaits unwind now; _run_benchmark_async translates the + # unwind into the run-timeout ExecutionError. + if self._task is not None: + self._task.cancel() + return + self._session.stop() + self._pipe.terminate_metrics_aggregator() + if self._interrupted_teardown_grace_s is not None: + # A wedged aggregator ignores the SIGTERM; escalate so the + # deadline stays a hard bound (cancelled when the drain finishes). + self._escalation = self._loop.call_later( + self._interrupted_teardown_grace_s, self._pipe.abandon_drain + ) + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None + if self._escalation is not None: + self._escalation.cancel() + self._escalation = None diff --git a/src/inference_endpoint/config/runtime_settings.py b/src/inference_endpoint/config/runtime_settings.py index c6aef2e49..591a42f4d 100644 --- a/src/inference_endpoint/config/runtime_settings.py +++ b/src/inference_endpoint/config/runtime_settings.py @@ -116,9 +116,14 @@ class RuntimeSettings: """Load pattern configuration""" min_duration_ms: int | None = field(default=None, kw_only=True) - """Sizing input, not a timer: n_samples_to_issue is derived as - target_qps x min_duration_ms when set (None/0 = no min_duration_ms - target: issue the dataset once). Only rulesets set this.""" + """Sizing input for the performance phase — never a runtime timer. + + When set, ``total_samples_to_issue()`` derives the sample count as + ``target_qps × min_duration_ms``; an explicit ``n_samples_to_issue`` + wins. ``None`` — or ``0`` from programmatic ruleset callers — means no + sizing target: issue the dataset once. Populated from + ``settings.runtime.min_duration_ms`` (schema-validated: poisson with an + explicit ``target_qps`` only) or set directly by rulesets.""" sample_order: SampleOrderSpec = field(default_factory=SampleOrderSpec, kw_only=True) """Sample-ordering strategy (default: without-replacement).""" diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index a08c2993e..f5208fe7b 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -22,9 +22,9 @@ from unittest.mock import MagicMock import pytest -from inference_endpoint.commands.benchmark.execute import ( +from inference_endpoint.commands.benchmark.execute import _PerfPhaseTimeout +from inference_endpoint.commands.benchmark.watchdog import ( SigintGovernor, - _PerfPhaseTimeout, sigint_policy, ) from inference_endpoint.load_generator.session import PhaseType From 8c572e24d0141c0eee4f4d2d08aa60e9df5c5d7c Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 19:04:57 -0700 Subject: [PATCH 43/45] refactor: PerfPhaseTimeout joins the other run timers in watchdog.py --- AGENTS.md | 1 + .../commands/benchmark/execute.py | 37 +--------------- .../commands/benchmark/watchdog.py | 43 +++++++++++++++++-- tests/unit/commands/test_watchdog.py | 10 ++--- 4 files changed, 47 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c196f3d9..706057234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,7 @@ src/inference_endpoint/ │ │ ├── cli.py # benchmark_app: offline, online, from-config subcommands │ │ ├── execute.py # Phased orchestration: setup_benchmark/run_benchmark_async/finalize_benchmark + BenchmarkContext; run_benchmark runs the main benchmark (cli._run dispatches run_audit when audit: is set) │ │ ├── profiling.py # Profiler-trigger protocol (vLLM /start_profile,/stop_profile) + ProfileController (URL derivation + start/stop/payload lifecycle) +│ │ ├── watchdog.py # PerfPhaseTimeout (perf cap), RunWatchdog (run deadline), SigintGovernor + sigint_policy (^C policy) │ │ ├── accuracy.py # AccuracyConfiguration + per-dataset scoring (_score_accuracy, OSL/response-count rollups, write_accuracy_results) │ │ └── pipeline.py # MetricsPipeline: async context manager for the ZMQ + metrics-aggregator/event-logger subprocess lifecycle (__aenter__/__aexit__/start/drain_and_build_report) + snapshot→Report │ ├── audit.py # run_audit() — compliance audit orchestrator (phases → verify → result) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index 99b78e872..cf70e9529 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -62,6 +62,7 @@ write_profiling_section, ) from inference_endpoint.commands.benchmark.watchdog import ( + PerfPhaseTimeout, RunWatchdog, SigintGovernor, sigint_policy, @@ -684,40 +685,6 @@ def _build_phases( return phases -class _PerfPhaseTimeout: - """Session-stop timer that bounds the PERFORMANCE phase only. - - ``max_duration_ms`` is a safety cap on the performance phase. The timer is - armed when the performance phase starts and cancelled as soon as any later - phase starts, so it can never truncate a subsequent accuracy phase: a - combined perf+accuracy run must let accuracy finish regardless of how long - perf ran. - """ - - def __init__( - self, - loop: asyncio.AbstractEventLoop, - max_duration_ms: int | None, - on_timeout: Callable[[], None], - ) -> None: - self._loop = loop - self._max_duration_ms = max_duration_ms - self._on_timeout = on_timeout - self._handle: asyncio.TimerHandle | None = None - - def on_phase_start(self, phase_type: PhaseType) -> None: - self.cancel() - if phase_type == PhaseType.PERFORMANCE and self._max_duration_ms is not None: - self._handle = self._loop.call_later( - self._max_duration_ms / 1000.0, self._on_timeout - ) - - def cancel(self) -> None: - if self._handle is not None: - self._handle.cancel() - self._handle = None - - async def _create_issuer( ctx: BenchmarkContext, loop: asyncio.AbstractEventLoop ) -> tuple[HttpClientSampleIssuer, HTTPEndpointClient]: @@ -921,7 +888,7 @@ def _on_perf_phase_timeout() -> None: # perf cap. session.stop_current_phase() - perf_timeout = _PerfPhaseTimeout( + perf_timeout = PerfPhaseTimeout( loop, max_duration_ms, _on_perf_phase_timeout ) diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index ddaf86018..221a23386 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -13,10 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run-abort machinery: the run's SIGINT policy and whole-run deadline. +"""Run timers and abort machinery for the benchmark orchestrator. -``SigintGovernor`` (+ ``sigint_policy``) is the one Ctrl-C policy; -``RunWatchdog`` enforces ``settings.timeouts.run_timeout_s``. +``PerfPhaseTimeout`` bounds the PERFORMANCE phase (``runtime.max_duration_ms``); +``RunWatchdog`` enforces ``settings.timeouts.run_timeout_s``; +``SigintGovernor`` (+ ``sigint_policy``) is the run's one Ctrl-C policy. """ from __future__ import annotations @@ -30,7 +31,7 @@ from collections.abc import Callable, Iterator from typing import TYPE_CHECKING -from inference_endpoint.load_generator.session import BenchmarkSession +from inference_endpoint.load_generator.session import BenchmarkSession, PhaseType if TYPE_CHECKING: from inference_endpoint.commands.benchmark.pipeline import MetricsPipeline @@ -38,6 +39,40 @@ logger = logging.getLogger(__name__) +class PerfPhaseTimeout: + """Session-stop timer that bounds the PERFORMANCE phase only. + + ``max_duration_ms`` is a safety cap on the performance phase. The timer is + armed when the performance phase starts and cancelled as soon as any later + phase starts, so it can never truncate a subsequent accuracy phase: a + combined perf+accuracy run must let accuracy finish regardless of how long + perf ran. + """ + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + max_duration_ms: int | None, + on_timeout: Callable[[], None], + ) -> None: + self._loop = loop + self._max_duration_ms = max_duration_ms + self._on_timeout = on_timeout + self._handle: asyncio.TimerHandle | None = None + + def on_phase_start(self, phase_type: PhaseType) -> None: + self.cancel() + if phase_type == PhaseType.PERFORMANCE and self._max_duration_ms is not None: + self._handle = self._loop.call_later( + self._max_duration_ms / 1000.0, self._on_timeout + ) + + def cancel(self) -> None: + if self._handle is not None: + self._handle.cancel() + self._handle = None + + class SigintGovernor: """The run's single SIGINT policy — installed ONCE per run. diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index f5208fe7b..bfca2b116 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -22,8 +22,8 @@ from unittest.mock import MagicMock import pytest -from inference_endpoint.commands.benchmark.execute import _PerfPhaseTimeout from inference_endpoint.commands.benchmark.watchdog import ( + PerfPhaseTimeout, SigintGovernor, sigint_policy, ) @@ -166,7 +166,7 @@ class TestPerfPhaseTimeout: @pytest.mark.asyncio async def test_cap_fires_after_max_duration(self): fired = asyncio.Event() - timeout = _PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) timeout.on_phase_start(PhaseType.PERFORMANCE) @@ -175,7 +175,7 @@ async def test_cap_fires_after_max_duration(self): @pytest.mark.asyncio async def test_accuracy_phase_start_disarms_pending_perf_cap(self): fired = asyncio.Event() - timeout = _PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) timeout.on_phase_start(PhaseType.PERFORMANCE) timeout.on_phase_start(PhaseType.ACCURACY) @@ -197,7 +197,7 @@ async def test_accuracy_phase_start_disarms_pending_perf_cap(self): ) async def test_never_armed(self, max_duration_ms, phases): fired = asyncio.Event() - timeout = _PerfPhaseTimeout( + timeout = PerfPhaseTimeout( asyncio.get_running_loop(), max_duration_ms, fired.set ) @@ -210,7 +210,7 @@ async def test_never_armed(self, max_duration_ms, phases): @pytest.mark.asyncio async def test_cancel_is_idempotent_and_disarms(self): fired = asyncio.Event() - timeout = _PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) + timeout = PerfPhaseTimeout(asyncio.get_running_loop(), 20, fired.set) timeout.cancel() # no handle yet — must not raise timeout.on_phase_start(PhaseType.PERFORMANCE) From c60c85f0e566c870fcfe71af9db09be480e5b49c Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 19:14:34 -0700 Subject: [PATCH 44/45] =?UTF-8?q?refactor(interrupt):=20one=20sigint=20sta?= =?UTF-8?q?te=20=E2=80=94=20normalize=20the=20governor=20at=20the=20public?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_benchmark_async gives callers without a governor (audit phases get one from run_audit; embedded/test callers) a passive SigintGovernor - never installed as a signal handler, interrupted stays False - so _run_benchmark_async requires it and the five scattered 'sigint is not None' guards collapse into plain attribute access. The adjacent report-persist guards in finalize merge into one block. Also restores the compliance-plan min_duration blockquote verbatim from main (the branch's condensed rewrite had dropped the 'merely derives a count' explanation; the file is now untouched by this MR). --- docs/compliance_audit_plan.md | 14 +++--- .../commands/benchmark/execute.py | 45 +++++++++---------- tests/unit/commands/test_benchmark.py | 15 ++++--- 3 files changed, 38 insertions(+), 36 deletions(-) diff --git a/docs/compliance_audit_plan.md b/docs/compliance_audit_plan.md index fa24f09cc..4f0625b44 100644 --- a/docs/compliance_audit_plan.md +++ b/docs/compliance_audit_plan.md @@ -523,12 +523,14 @@ Two scenarios must be covered: **Offline** (`max_throughput`) and **SingleStream > catches a crashed run — but the examples default to equal for the clearest, least-contentious > comparison. -> **No duration floor (current limitation).** Runs are count-driven: the load-generator stop -> check (`session.py`) halts a phase on **sample count** or **`runtime.max_duration_ms`** -> only, and TEST04 drives explicit `samples` / `audit_samples` counts. MLCommons' 10-minute -> compliance minimum therefore is **not** enforced today; combining a count floor with a -> duration floor ("AND-semantics") is future work. Set `samples` large enough that each phase -> reaches a stable throughput on its own. +> **`min_duration` is not a duration floor (current limitation).** The load-generator stop +> check (`session.py`) halts a phase on **sample count** or **`max_duration_ms`** only; +> `min_duration_ms` merely _derives_ a count when no explicit count is set. Because TEST04 +> drives an explicit `samples` count, each phase stops at `samples` and `min_duration_ms` is +> **not** honored as a "run for at least 10 minutes" floor. MLCommons' 10-minute compliance +> minimum therefore is **not** enforced today; combining a count floor with a duration floor +> ("AND-semantics") is future work. Set `samples` large enough that each phase reaches a +> stable throughput on its own. Both scenarios ship as committed configs (see also [`compliance/audit_test/README.md`](../src/inference_endpoint/compliance/audit_test/README.md)): diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index cf70e9529..c50f1a818 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -776,7 +776,7 @@ async def _run_benchmark_async( loop: asyncio.AbstractEventLoop, *, deadline: float | None = None, - sigint: SigintGovernor | None = None, + sigint: SigintGovernor, ) -> BenchmarkResult: """Run async benchmark session.""" config = ctx.config @@ -822,8 +822,7 @@ async def _run_benchmark_async( loop, deadline, pipe, config.settings.timeouts.interrupted_teardown_grace_s ) watchdog.bind_task(asyncio.current_task()) - if sigint is not None: - sigint.bind_task(asyncio.current_task(), loop) + sigint.bind_task(asyncio.current_task(), loop) try: tmpfs_dir.mkdir(parents=True, exist_ok=True) @@ -862,10 +861,9 @@ async def _run_benchmark_async( session_id=session_id, ) watchdog.bind_session(session) - if sigint is not None: - # ^C: graceful stop + grace timer; expiry abandons a - # wedged drain (SIGTERM→SIGKILL via abandon_drain). - sigint.bind_session(session, pipe.abandon_drain) + # ^C: graceful stop + grace timer; expiry abandons a + # wedged drain (SIGTERM→SIGKILL via abandon_drain). + sigint.bind_session(session, pipe.abandon_drain) phases = _build_phases(ctx, perf_strategy=agentic_inference_strategy) max_duration_ms = ( @@ -959,19 +957,16 @@ def _on_phase_start(phase: PhaseConfig) -> None: # swallowed when the run is already failing or ^C'd — # the abort (exit 130) outranks a drain error its own # grace escalation may have caused. - if session_completed_normally and not ( - sigint is not None and sigint.interrupted - ): + if session_completed_normally and not sigint.interrupted: raise logger.warning( "Drain/report build error suppressed (run " "already failing): %s", e, ) - if sigint is not None: - # Drain finished (or failed) on its own — the teardown - # grace timer has nothing left to bound. - sigint.cancel_grace() + # Drain finished (or failed) on its own — the grace + # timer has nothing left to bound. + sigint.cancel_grace() finally: # Runs on every path, including a setup error before session.run # (which never reaches the session finally above). pbar.close() is @@ -1023,7 +1018,7 @@ def _on_phase_start(phase: PhaseConfig) -> None: tmpfs_dir=tmpfs_dir, profiling=profiler.payload(), run_timed_out=watchdog.fired, - user_interrupted=sigint.interrupted if sigint is not None else False, + user_interrupted=sigint.interrupted, ) @@ -1048,8 +1043,14 @@ def run_benchmark_async( When ``deadline`` is None and ``settings.timeouts.run_timeout_s`` is set, computes its own deadline at entry, so each audit phase gets a full - per-phase budget. + per-phase budget. A caller without a governor (embedded/test use) gets a + passive one — never installed as a signal handler, ``interrupted`` stays + False — so downstream code has exactly one sigint state to reason about. """ + if sigint is None: + sigint = SigintGovernor( + ctx.config.settings.timeouts.interrupted_teardown_grace_s + ) if deadline is None: deadline = _run_deadline(ctx.config) loop = LoopManager().default_loop @@ -1218,14 +1219,12 @@ def finalize_benchmark(ctx: BenchmarkContext, bench: BenchmarkResult) -> None: bench.report = report raise finally: - # Attach the per-dataset accuracy list so result_summary.json, the - # console summary, and report.txt all carry it (stays [] on a scoring - # failure). - if report is not None: - report = msgspec.structs.replace(report, accuracy=accuracy_scores) - # Display the report + write result_summary.json / report.txt. + # Attach the per-dataset accuracy list (stays [] on a scoring + # failure), then write result_summary.json / report.txt. if report is not None: - _write_report_artifacts(ctx, report, bench.profiling) + final_report = msgspec.structs.replace(report, accuracy=accuracy_scores) + _write_report_artifacts(ctx, final_report, bench.profiling) + report = final_report bench.report = report _summarize_and_log_metrics(ctx, report, result, collector) diff --git a/tests/unit/commands/test_benchmark.py b/tests/unit/commands/test_benchmark.py index d009b9225..573dab2a8 100644 --- a/tests/unit/commands/test_benchmark.py +++ b/tests/unit/commands/test_benchmark.py @@ -56,6 +56,7 @@ _render_profile_status, write_profiling_section, ) +from inference_endpoint.commands.benchmark.watchdog import SigintGovernor from inference_endpoint.config.runtime_settings import RuntimeSettings from inference_endpoint.config.schema import ( AgenticInferenceConfig, @@ -1107,7 +1108,7 @@ async def _capture_launch(service_configs, *, timeout): loop = asyncio.get_event_loop() with pytest.raises(KeyboardInterrupt): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) aggregator_cfg = next(c for c in captured if "metrics_aggregator" in c.module) args = aggregator_cfg.args @@ -1177,7 +1178,7 @@ async def _capture_launch(service_configs, *, timeout): loop = asyncio.get_event_loop() with pytest.raises(KeyboardInterrupt): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) aggregator_cfg = next(c for c in captured if "metrics_aggregator" in c.module) args = aggregator_cfg.args @@ -1231,7 +1232,7 @@ async def _capture_launch(service_configs, *, timeout): loop = asyncio.get_event_loop() with pytest.raises(RuntimeError, match="simulated mid-run crash"): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) shm = Path("/dev/shm") tmpfs_base = shm if shm.exists() else Path(tempfile.gettempdir()) @@ -1287,7 +1288,7 @@ async def _launch_ok(service_configs, *, timeout): loop = asyncio.get_event_loop() with pytest.raises(RuntimeError, match="setup boom"): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) # __aexit__ kills the services (drain never ran) → launcher.terminate_all(); # called exactly once, and never for a clean run. @@ -1336,7 +1337,7 @@ async def _launch_ok(service_configs, *, timeout): loop = asyncio.get_event_loop() with pytest.raises(SetupError, match="connect boom"): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) # The setup-error path never drains, so __aexit__ kills the services once. MockLauncher.return_value.terminate_all.assert_called_once() @@ -1393,7 +1394,7 @@ async def _launch_ok(service_configs, *, timeout): loop = asyncio.get_event_loop() with pytest.raises(RuntimeError, match="setup boom"): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) # Client was created; the run failed before the session finally, so the # outer finally must have shut it down (idempotent, called once here). @@ -1484,7 +1485,7 @@ async def _launch_ok(service_configs, *, timeout): MockLauncher.return_value.launch = _launch_ok with pytest.raises(expected_error, match=expected_match): - await _run_benchmark_async(ctx, loop) + await _run_benchmark_async(ctx, loop, sigint=SigintGovernor(None)) class TestAccuracyOnlyDatasetLoading: From 562c90c3f214dbfafc3239c27642aa365bad1c8b Mon Sep 17 00:00:00 2001 From: Viraat Chandra Date: Thu, 20 Aug 2026 19:31:32 -0700 Subject: [PATCH 45/45] =?UTF-8?q?fix(interrupt):=20review-round=20fixes=20?= =?UTF-8?q?=E2=80=94=20pre-session=20^C=20cancels=20cleanly;=20run=5Ftimeo?= =?UTF-8?q?ut=5Fs=20is=20a=20hard=20bound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arekay's review round: - HIGH: a ^C during service launch / endpoint connect (task bound, session not yet) previously raised raw KeyboardInterrupt out of the loop, skipping the pipeline __aexit__ and orphaning the aggregator/event-logger children. The governor now cancels the run task - exactly like the watchdog's pre-session fire - so the unwind kills the children; _run_benchmark_async maps the cancellation back to KeyboardInterrupt for exit 130. Unit test pins the cancel. - MED: interrupted_teardown_grace_s: null no longer softens run_timeout_s - the watchdog's SIGKILL escalation always arms (null grace applies to the ^C path only, as documented). - nits: redundant cyclopts help= strings that duplicated the field description are dropped (cyclopts falls back to the description); templates regenerated. --- .../commands/benchmark/execute.py | 6 ++++ .../commands/benchmark/watchdog.py | 29 +++++++++++------ src/inference_endpoint/config/schema.py | 31 ++----------------- tests/unit/commands/test_watchdog.py | 18 +++++++++++ 4 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/inference_endpoint/commands/benchmark/execute.py b/src/inference_endpoint/commands/benchmark/execute.py index c50f1a818..a80fca3b6 100644 --- a/src/inference_endpoint/commands/benchmark/execute.py +++ b/src/inference_endpoint/commands/benchmark/execute.py @@ -997,6 +997,12 @@ def _on_phase_start(phase: PhaseConfig) -> None: salvage_err, tmpfs_dir, ) + if sigint.interrupted and isinstance(e, asyncio.CancelledError): + # Pre-session ^C: the governor cancelled this task (so the + # pipeline __aexit__ above killed the service children). Surface + # as the user's abort for exit 130. Checked before the watchdog: + # the user's ^C is the truthful cause even if both fired. + raise KeyboardInterrupt from e if watchdog.fired and isinstance(e, Exception | asyncio.CancelledError): # The watchdog aborted the run: the pre-session fire cancels this # task, and a mid-teardown fire can surface as a launch/drain diff --git a/src/inference_endpoint/commands/benchmark/watchdog.py b/src/inference_endpoint/commands/benchmark/watchdog.py index 221a23386..5cfdbff67 100644 --- a/src/inference_endpoint/commands/benchmark/watchdog.py +++ b/src/inference_endpoint/commands/benchmark/watchdog.py @@ -144,15 +144,23 @@ def __call__(self, signum: int, frame: types.FrameType | None) -> None: return self.interrupted = True if ( - self._session is None - or self._task is None + self._task is None or self._task.done() or self._loop is None or not self._loop.is_running() ): - # No live run to stop gracefully; call_soon_threadsafe on a - # stopped loop would silently swallow the ^C. + # No live run at all (sync setup/finalize, between audit phases); + # call_soon_threadsafe on a stopped loop would silently swallow + # the ^C. raise KeyboardInterrupt + if self._session is None: + # Run task live but no session yet (service launch / endpoint + # connect): cancel the task — same as the watchdog's pre-session + # fire — so the pipeline __aexit__ kills the service children + # instead of a raw raise orphaning them. _run_benchmark_async + # maps the unwind back to KeyboardInterrupt. + self._loop.call_soon_threadsafe(self._task.cancel) + return logger.warning("SIGINT received: stopping benchmark gracefully") # Signal handlers run at arbitrary bytecode boundaries: hand the stop # to the loop via its one signal-safe entry point. @@ -244,12 +252,13 @@ def _fire(self) -> None: return self._session.stop() self._pipe.terminate_metrics_aggregator() - if self._interrupted_teardown_grace_s is not None: - # A wedged aggregator ignores the SIGTERM; escalate so the - # deadline stays a hard bound (cancelled when the drain finishes). - self._escalation = self._loop.call_later( - self._interrupted_teardown_grace_s, self._pipe.abandon_drain - ) + # A wedged aggregator ignores the SIGTERM; always escalate so + # run_timeout_s stays a hard bound — a null ^C-grace must not soften + # it (cancelled when the drain finishes on its own). + grace = self._interrupted_teardown_grace_s + self._escalation = self._loop.call_later( + grace if grace is not None else 30.0, self._pipe.abandon_drain + ) def cancel(self) -> None: if self._handle is not None: diff --git a/src/inference_endpoint/config/schema.py b/src/inference_endpoint/config/schema.py index a9e762829..18ad22e10 100644 --- a/src/inference_endpoint/config/schema.py +++ b/src/inference_endpoint/config/schema.py @@ -607,19 +607,7 @@ class RuntimeConfig(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - min_duration_ms: Annotated[ - int | None, - cyclopts.Parameter( - help=( - "POISSON MODE ONLY (requires an explicit target_qps; rejected " - "for offline/max_throughput and concurrency runs). Size the " - "run by time: derive the sample count as target_qps × " - "samples (ms, or suffix: 600s, 10m). Precedence: an explicit " - "--num-samples always wins; unset, this derivation applies; " - "both unset = issue the dataset once" - ), - ), - ] = Field( + min_duration_ms: int | None = Field( None, gt=0, description=( @@ -829,13 +817,7 @@ class Timeouts(WithUpdatesMixin, BaseModel): run_timeout_s: Annotated[ float | None, - cyclopts.Parameter( - alias="--timeout", - help=( - "Whole-run watchdog in seconds (None = off). Firing aborts the " - "run and marks the report INTERRUPTED." - ), - ), + cyclopts.Parameter(alias="--timeout"), ] = Field( None, gt=0, @@ -1007,14 +989,7 @@ class Settings(WithUpdatesMixin, BaseModel): ) metrics_tokenizer_workers: Annotated[ int, - cyclopts.Parameter( - alias="--metrics-tokenizer-workers", - help=( - "In-process tokenizer threads for live (mid-run) ISL/OSL/TPOT in " - "the metrics aggregator. 0 defers all tokenization to the " - "end-of-run drain, which always uses the auto-sized sharded pool." - ), - ), + cyclopts.Parameter(alias="--metrics-tokenizer-workers"), ] = Field( 4, ge=0, diff --git a/tests/unit/commands/test_watchdog.py b/tests/unit/commands/test_watchdog.py index bfca2b116..1590b5ca1 100644 --- a/tests/unit/commands/test_watchdog.py +++ b/tests/unit/commands/test_watchdog.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio +import contextlib import signal from unittest.mock import MagicMock @@ -42,6 +43,23 @@ def test_unbound_sigint_raises_keyboard_interrupt(self): _fire(gov) assert gov.interrupted + @pytest.mark.asyncio + async def test_presession_sigint_cancels_run_task(self): + """^C after the task is bound but before the session exists must + cancel the task (unwinding kills the service children via the + pipeline __aexit__) — never raise raw and orphan them.""" + gov = SigintGovernor(interrupted_teardown_grace_s=30.0) + run_task = asyncio.create_task(asyncio.sleep(30)) + await asyncio.sleep(0) + gov.bind_task(run_task, asyncio.get_running_loop()) + + _fire(gov) # no raise: session not bound yet + + assert gov.interrupted + with contextlib.suppress(asyncio.CancelledError): + await asyncio.wait_for(run_task, timeout=2.0) + assert run_task.cancelled() + def test_sigint_after_loop_returned_raises_immediately(self): """A ^C during sync finalization must not be swallowed.