diff --git a/.importlinter b/.importlinter index d61c0880..0c042122 100644 --- a/.importlinter +++ b/.importlinter @@ -14,8 +14,10 @@ source_modules = aai_cli.config aai_cli.config_builder aai_cli.context + aai_cli.der aai_cli.environments aai_cli.errors + aai_cli.eval_data aai_cli.follow aai_cli.help_panels aai_cli.help_text @@ -32,6 +34,7 @@ source_modules = aai_cli.transcribe_batch aai_cli.transcribe_exec aai_cli.transcribe_render + aai_cli.wer aai_cli.ws aai_cli.youtube forbidden_modules = @@ -47,6 +50,7 @@ modules = aai_cli.commands.deploy aai_cli.commands.dev aai_cli.commands.doctor + aai_cli.commands.evaluate aai_cli.commands.init aai_cli.commands.keys aai_cli.commands.llm @@ -66,9 +70,12 @@ source_modules = aai_cli.client aai_cli.config aai_cli.config_builder + aai_cli.der aai_cli.environments aai_cli.errors + aai_cli.eval_data aai_cli.llm aai_cli.telemetry + aai_cli.wer forbidden_modules = rich diff --git a/README.md b/README.md index 1bdf9898..60715a1f 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Requires Python 3.12+. On Linux, install PortAudio once for microphone support ( - **Real-time streaming**: `assembly stream` transcribes the microphone, a file, or a URL live — on macOS it can capture system audio too. - **Voice agent**: `assembly agent` runs a full-duplex spoken conversation in your terminal (use headphones). - **LLM Gateway**: `assembly llm` prompts an LLM over a transcript, stdin, or a live stream (`assembly stream --llm "summarize as I talk"`). +- **Model evaluation**: `assembly eval` transcribes a Hugging Face dataset or a local `.csv`/`.jsonl` manifest and scores WER against its references (plus DER with `--speaker-labels`) — handy for picking a speech model. - **Starter apps**: `assembly init` scaffolds a self-contained FastAPI + HTML app (`audio-transcription`, `live-captions`, `voice-agent`). - **Code generation**: add `--show-code` to `transcribe`/`stream`/`agent` to print the equivalent Python SDK script instead of running. - **Account self-service**: `assembly keys` / `balance` / `usage` / `limits` / `sessions` / `audit` via browser login. diff --git a/aai_cli/commands/evaluate.py b/aai_cli/commands/evaluate.py new file mode 100644 index 00000000..804d2e04 --- /dev/null +++ b/aai_cli/commands/evaluate.py @@ -0,0 +1,212 @@ +"""`assembly eval` — transcribe an evaluation dataset and score it against references. + +WER (via jiwer) against the dataset's reference texts; with ``--speaker-labels`` +also DER (via pyannote.metrics) against its reference speaker turns. The module +is named ``evaluate`` because importing a module named ``eval`` would shadow the +builtin; the command itself registers as ``eval``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import assemblyai as aai +import typer +from rich.console import RenderableType + +from aai_cli import client, config, der, eval_data, help_panels, jsonshape, options, output, wer +from aai_cli.context import AppState, run_command +from aai_cli.help_text import examples_epilog + +app = typer.Typer() + + +def _pct(value: object) -> str: + return f"{jsonshape.as_float(value):.2%}" + + +def _hypothesis_turns(transcript: aai.Transcript) -> list[der.Turn]: + """The transcript's diarized utterances as DER hypothesis turns (ms → seconds).""" + return [ + der.Turn( + speaker=str(getattr(utterance, "speaker", "")), + start=jsonshape.as_float(getattr(utterance, "start", None)) / 1000, + end=jsonshape.as_float(getattr(utterance, "end", None)) / 1000, + ) + for utterance in jsonshape.object_list(getattr(transcript, "utterances", None)) + ] + + +@dataclass(frozen=True) +class _ItemResult: + """One scored row: the emitted dict plus the scores kept for pooling.""" + + row: dict[str, object] + words: wer.Score | None + speakers: der.DerScore | None + + +def _score_item( + item: eval_data.EvalItem, transcript: aai.Transcript, *, collar: float +) -> _ItemResult: + row: dict[str, object] = {"item": item.item_id} + words: wer.Score | None = None + speakers: der.DerScore | None = None + if item.reference is not None: + words = wer.score(item.reference, str(transcript.text or "")) + row.update({"words": words.words, "errors": words.errors, "wer": words.wer}) + if item.turns is not None: + speakers = der.score(item.turns, _hypothesis_turns(transcript), collar=collar) + row["der"] = speakers.der + return _ItemResult(row=row, words=words, speakers=speakers) + + +def _payload( + label: str, speech_model: aai.SpeechModel | None, results: list[_ItemResult] +) -> dict[str, object]: + payload: dict[str, object] = { + "dataset": label, + "speech_model": speech_model.value if speech_model else None, + "items": len(results), + "rows": [result.row for result in results], + } + word_scores = [result.words for result in results if result.words is not None] + if word_scores: + total = wer.pooled(word_scores) + payload.update({"words": total.words, "errors": total.errors, "wer": total.wer}) + der_scores = [result.speakers for result in results if result.speakers is not None] + if der_scores: + payload["der"] = der.pooled(der_scores).der + return payload + + +def _summary(payload: dict[str, object]) -> str: + parts: list[str] = [] + if "wer" in payload: + errors = jsonshape.as_int(payload.get("errors")) + noun = "error" if errors == 1 else "errors" + parts.append( + f"WER {_pct(payload.get('wer'))} ({errors} {noun} / {payload.get('words')} words)" + ) + if "der" in payload: + parts.append(f"DER {_pct(payload.get('der'))}") + return output.heading(" ".join(parts)) + + +def _render(payload: dict[str, object]) -> RenderableType: + has_wer = "wer" in payload + has_der = "der" in payload + columns = [ + "ITEM", + *(["WORDS", "ERRORS", "WER"] if has_wer else []), + *(["DER"] if has_der else []), + ] + table = output.data_table(*columns) + for row in jsonshape.mapping_list(payload.get("rows")): + cells = [str(row.get("item"))] + if has_wer: + cells += [str(row.get("words")), str(row.get("errors")), _pct(row.get("wer"))] + if has_der: + cells.append(_pct(row.get("der"))) + table.add_row(*cells) + model = payload.get("speech_model") or "default model" + return output.stack( + output.muted(f"{payload.get('dataset')} · {model}"), table, _summary(payload) + ) + + +@app.command( + name="eval", + rich_help_panel=help_panels.TRANSCRIPTION, + epilog=examples_epilog( + [ + ("Score a model on 10 rows of an HF dataset", "assembly eval distil-whisper/meanwhile"), + ( + "Compare models on your own audio", + "assembly eval calls.csv --speech-model universal", + ), + ( + "Score diarization too (WER + DER)", + "assembly eval agent-calls.jsonl --speaker-labels", + ), + ( + "Pick a subset/split and more rows", + "assembly eval mozilla-foundation/common_voice_17_0 --subset en --limit 50", + ), + ] + ), +) +def evaluate( + ctx: typer.Context, + dataset: str = typer.Argument( + ..., + help="Hugging Face dataset id, or a local .csv/.jsonl manifest with audio + text columns.", + ), + split: str | None = typer.Option( + None, "--split", help="Hugging Face split to score (default: test)." + ), + subset: str | None = typer.Option( + None, "--subset", help="Hugging Face config/subset name (e.g. a language)." + ), + limit: int = typer.Option(10, "--limit", min=1, max=100, help="Rows to evaluate (1-100)."), + audio_column: str | None = typer.Option( + None, "--audio-column", help="Audio column name (default: auto-detect)." + ), + text_column: str | None = typer.Option( + None, "--text-column", help="Reference text column name (default: auto-detect)." + ), + speech_model: aai.SpeechModel | None = typer.Option( + None, "--speech-model", help="Speech model to evaluate." + ), + language_code: str | None = typer.Option( + None, "--language-code", help="Force a language (e.g. en_us)." + ), + speaker_labels: bool = typer.Option( + False, + "--speaker-labels", + help="Diarize and also score DER against the dataset's reference speaker turns (speakers/timestamps_start/timestamps_end columns, in seconds).", + ), + collar: float = typer.Option( + 1.0, + "--collar", + min=0.0, + help="DER forgiveness (seconds) around each reference turn boundary.", + ), + json_out: bool = options.json_option("Output the rows and summary as one JSON object."), +) -> None: + """Transcribe an evaluation dataset and score WER against its reference texts. + + Handy for picking a model: run once per --speech-model and compare. Datasets + come from the Hugging Face Hub (gated ones need HF_TOKEN) or a local .csv/.jsonl + manifest. --speaker-labels also scores diarization (DER) against reference + speaker turns. + """ + + def body(state: AppState, json_mode: bool) -> None: + data = eval_data.load( + dataset, + split=split, + subset=subset, + audio_column=audio_column, + text_column=text_column, + limit=limit, + with_speakers=speaker_labels, + ) + api_key = config.resolve_api_key(profile=state.profile) + transcription_config = aai.TranscriptionConfig( + speech_model=speech_model, + language_code=language_code, + speaker_labels=speaker_labels or None, + ) + results: list[_ItemResult] = [] + for index, item in enumerate(data.items, start=1): + with output.status( + f"[{index}/{len(data.items)}] Transcribing {item.item_id}…", + json_mode=json_mode, + quiet=state.quiet, + ): + transcript = client.transcribe(api_key, item.audio, config=transcription_config) + results.append(_score_item(item, transcript, collar=collar)) + output.emit(_payload(data.label, speech_model, results), _render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/der.py b/aai_cli/der.py new file mode 100644 index 00000000..e9de35a9 --- /dev/null +++ b/aai_cli/der.py @@ -0,0 +1,95 @@ +"""Diarization error rate (DER) scoring for `assembly eval --speaker-labels`. + +A thin shim over :mod:`pyannote.metrics` — the de-facto standard DER +implementation, including its optimal reference↔hypothesis speaker mapping — +so the alignment math is never re-derived here. pyannote drags numpy/scipy/ +pandas along, so it is imported lazily inside :func:`score`; the dataclasses +here stay import-cheap for the command layer. No SDK, no Rich. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pydantic import TypeAdapter + +if TYPE_CHECKING: + from pyannote.core import Annotation + +# pyannote types the metric call as a bare float; with ``detailed=True`` it +# actually returns the components dict — validate that shape instead of casting. +_COMPONENTS: TypeAdapter[dict[str, float]] = TypeAdapter(dict[str, float]) + + +@dataclass(frozen=True) +class Turn: + """One speaker turn: who spoke from ``start`` to ``end`` (seconds).""" + + speaker: str + start: float + end: float + + +@dataclass(frozen=True) +class DerScore: + """DER components in seconds of speech; pooled across files for corpus DER.""" + + missed: float + false_alarm: float + confusion: float + total: float + + @property + def der(self) -> float: + return (self.missed + self.false_alarm + self.confusion) / self.total + + +def _annotation(turns: list[Turn]) -> Annotation: + from pyannote.core import Annotation, Segment + + annotation = Annotation() + # The list index keys each track, so two overlapping turns by the same + # speaker don't collapse into one. + for track, turn in enumerate(turns): + annotation[Segment(turn.start, turn.end), track] = turn.speaker + return annotation + + +def score(reference: list[Turn], hypothesis: list[Turn], *, collar: float = 0.0) -> DerScore: + """Score hypothesis speaker turns against the reference diarization. + + The caller guarantees a non-empty reference (the dataset loader rejects + rows without speaker turns), so ``DerScore.der`` is always well-defined. + ``collar`` forgives that many seconds around each reference turn boundary. + """ + from pyannote.core import Segment, Timeline + from pyannote.metrics.diarization import DiarizationErrorRate + + metric = DiarizationErrorRate(collar=collar) + # An explicit evaluation extent (audio start through the last turn either + # side heard); without it pyannote warns about approximating the UEM. + extent = max(turn.end for turn in [*reference, *hypothesis]) + components: object = metric( + _annotation(reference), + _annotation(hypothesis), + uem=Timeline([Segment(0.0, extent)]), + detailed=True, + ) + detail = _COMPONENTS.validate_python(components) + return DerScore( + missed=detail["missed detection"], + false_alarm=detail["false alarm"], + confusion=detail["confusion"], + total=detail["total"], + ) + + +def pooled(scores: list[DerScore]) -> DerScore: + """Corpus-level score: error seconds over total reference speech seconds.""" + return DerScore( + missed=sum(item.missed for item in scores), + false_alarm=sum(item.false_alarm for item in scores), + confusion=sum(item.confusion for item in scores), + total=sum(item.total for item in scores), + ) diff --git a/aai_cli/eval_data.py b/aai_cli/eval_data.py new file mode 100644 index 00000000..dde0be36 --- /dev/null +++ b/aai_cli/eval_data.py @@ -0,0 +1,444 @@ +"""Dataset loading for `assembly eval`: local manifests and Hugging Face datasets. + +Two source shapes, one output: a list of (audio, reference-text) items. + +* A **local manifest** — a ``.csv`` or ``.jsonl`` file with an audio column + (path or URL) and a reference-text column. Relative audio paths resolve + against the manifest's directory. +* A **Hugging Face dataset id** (e.g. ``hf-internal-testing/librispeech_asr_dummy``), + fetched through the hub's datasets-server REST API — no heavyweight + ``datasets`` dependency, and the audio arrives as hosted URLs the AssemblyAI + API ingests directly. Gated/private datasets authenticate via ``HF_TOKEN``. + +With ``with_speakers`` (the ``--speaker-labels`` flag), rows must also carry +diarization references as the parallel ``speakers`` / ``timestamps_start`` / +``timestamps_end`` arrays the Hugging Face diarization datasets use (seconds); +reference text then becomes optional, since diarization sets often have none. +""" + +from __future__ import annotations + +import csv +import json +import os +import re +from dataclasses import dataclass +from http import HTTPStatus +from pathlib import Path + +import httpx2 as httpx + +from aai_cli import der, jsonshape, wer +from aai_cli.errors import APIError, CLIError, UsageError + +_DATASETS_SERVER = "https://datasets-server.huggingface.co" +_TIMEOUT = 30.0 # pragma: no mutate (request timeout; nothing observable to assert) +_MANIFEST_SUFFIXES = (".csv", ".jsonl") +# Hub ids are `name` or `namespace/name`; rejecting anything else keeps a typo'd +# local path from being sent to the hub as if it were a dataset id. +_HF_ID_RE = re.compile(r"^[\w.-]+(?:/[\w.-]+)?$") + +# Column auto-detection, in preference order: the names the common ASR datasets +# and manifest tools use (HF audio datasets, Common Voice, NeMo manifests). +_AUDIO_COLUMNS = ("audio", "audio_filepath", "audio_url", "path", "file") +_TEXT_COLUMNS = ("text", "sentence", "transcription", "transcript", "normalized_text") +# Diarization references: the parallel-array convention the Hugging Face +# diarization datasets (diarizers-community/*) use, in seconds. +_SPEAKER_COLUMNS = ("speakers", "timestamps_start", "timestamps_end") + + +@dataclass(frozen=True) +class EvalItem: + """One evaluation row: an audio source (path or URL) plus its references — + text for WER, speaker turns for DER (each optional, never both absent).""" + + item_id: str + audio: str + reference: str | None + turns: list[der.Turn] | None = None + + +@dataclass(frozen=True) +class EvalDataset: + """The loaded items plus a human label naming what was resolved (file, or + ``dataset · subset/split``) for the result header.""" + + label: str + items: list[EvalItem] + + +def load( + dataset: str, + *, + split: str | None = None, + subset: str | None = None, + audio_column: str | None = None, + text_column: str | None = None, + limit: int, + with_speakers: bool = False, +) -> EvalDataset: + """Load evaluation items from a local manifest or a Hugging Face dataset id.""" + path = Path(dataset) + if path.suffix in _MANIFEST_SUFFIXES or path.is_file(): + if split is not None or subset is not None: + raise UsageError( + "--split/--subset apply to Hugging Face datasets, not local manifests." + ) + return _load_manifest( + path, + audio_column=audio_column, + text_column=text_column, + limit=limit, + with_speakers=with_speakers, + ) + return _load_hf( + dataset, + split=split, + subset=subset, + audio_column=audio_column, + text_column=text_column, + limit=limit, + with_speakers=with_speakers, + ) + + +def _pick_column( + available: list[str], requested: str | None, candidates: tuple[str, ...], flag: str +) -> str: + """Resolve a column name: the explicit request, else the first known candidate.""" + if requested is not None: + if requested in available: + return requested + raise UsageError( + f"The dataset has no '{requested}' column (columns: {', '.join(available)}).", + suggestion=f"Pass {flag} with one of the existing columns.", + ) + for candidate in candidates: + if candidate in available: + return candidate + raise UsageError( + f"Could not find a {candidates[0]} column (columns: {', '.join(available)}).", + suggestion=f"Name it with {flag}.", + ) + + +def _resolve_columns( + columns: list[str], + *, + audio_column: str | None, + text_column: str | None, + with_speakers: bool, +) -> tuple[str, str | None]: + """The (audio, text) columns to read. Text is only optional when speaker + turns are being scored instead — diarization datasets often carry no text.""" + audio_col = _pick_column(columns, audio_column, _AUDIO_COLUMNS, "--audio-column") + if with_speakers: + missing = [name for name in _SPEAKER_COLUMNS if name not in columns] + if missing: + raise UsageError( + f"--speaker-labels needs speaker-turn columns; missing: {', '.join(missing)} " + f"(columns: {', '.join(columns)}).", + suggestion="Rows carry parallel speakers/timestamps_start/timestamps_end " + "arrays in seconds (arrays need a .jsonl manifest, not .csv).", + ) + if text_column is None and not any(name in columns for name in _TEXT_COLUMNS): + return audio_col, None + return audio_col, _pick_column(columns, text_column, _TEXT_COLUMNS, "--text-column") + + +def _checked_reference(item_id: str, reference: str) -> str: + """Reject a reference that normalizes to no words — WER would be undefined.""" + if not wer.normalize_words(reference): + raise UsageError( + f"{item_id} has an empty reference text after normalization.", + suggestion="Fix the row, or point --text-column at the right column.", + ) + return reference + + +def _row_reference(cells: dict[str, object], text_col: str | None, item_id: str) -> str | None: + if text_col is None: + return None + return _checked_reference(item_id, str(cells.get(text_col) or "")) + + +def _row_turns(cells: dict[str, object], item_id: str) -> list[der.Turn]: + """The row's reference speaker turns from the parallel-array columns.""" + speakers = jsonshape.object_list(cells.get("speakers")) + starts = jsonshape.object_list(cells.get("timestamps_start")) + ends = jsonshape.object_list(cells.get("timestamps_end")) + if not speakers or len(starts) != len(speakers) or len(ends) != len(speakers): + raise UsageError( + f"{item_id} needs non-empty, equal-length speakers/timestamps_start/" + "timestamps_end arrays.", + suggestion="Each row lists who spoke plus matching start/end seconds.", + ) + return [ + der.Turn( + speaker=str(speakers[i]), + start=jsonshape.as_float(starts[i]), + end=jsonshape.as_float(ends[i]), + ) + for i in range(len(speakers)) + ] + + +# ---------------------------------------------------------------- local manifests + + +def _manifest_rows(path: Path) -> list[dict[str, object]]: + if path.suffix == ".csv": + with path.open(newline="", encoding="utf-8") as handle: + return [dict(row) for row in csv.DictReader(handle)] + rows: list[dict[str, object]] = [] + for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + try: + data: object = json.loads(line) + except json.JSONDecodeError as exc: + raise UsageError(f"{path.name} line {lineno} is not valid JSON: {exc}") from exc + mapping = jsonshape.as_mapping(data) + if mapping is None: + raise UsageError(f"{path.name} line {lineno} is not a JSON object.") + rows.append(mapping) + return rows + + +def _load_manifest( + path: Path, + *, + audio_column: str | None, + text_column: str | None, + limit: int, + with_speakers: bool, +) -> EvalDataset: + if not path.is_file(): + raise CLIError( + f"Manifest not found: {path}", + error_type="file_not_found", + exit_code=2, + suggestion="Pass a .csv/.jsonl manifest path, or a Hugging Face dataset id.", + ) + rows = _manifest_rows(path) + if not rows: + raise UsageError(f"Manifest {path.name} has no rows.") + audio_col, text_col = _resolve_columns( + list(rows[0]), + audio_column=audio_column, + text_column=text_column, + with_speakers=with_speakers, + ) + items = [ + _manifest_item( + path, index, row, audio_col=audio_col, text_col=text_col, with_speakers=with_speakers + ) + for index, row in enumerate(rows[:limit], start=1) + ] + return EvalDataset(label=path.name, items=items) + + +def _manifest_item( + path: Path, + index: int, + row: dict[str, object], + *, + audio_col: str, + text_col: str | None, + with_speakers: bool, +) -> EvalItem: + audio = str(row.get(audio_col) or "") + if not audio: + raise UsageError(f"{path.name} row {index} has no '{audio_col}' value.") + if not audio.startswith(("http://", "https://")): + # pathlib drops the left side when the right side is absolute, so this + # resolves relative paths against the manifest dir and keeps absolute ones. + resolved = path.parent / audio + if not resolved.is_file(): + raise CLIError( + f"Audio file not found: {resolved}", + error_type="file_not_found", + exit_code=2, + suggestion=f"Manifest audio paths resolve relative to {path.parent}.", + ) + audio = str(resolved) + item_id = Path(audio).name + return EvalItem( + item_id=item_id, + audio=audio, + reference=_row_reference(row, text_col, item_id), + turns=_row_turns(row, item_id) if with_speakers else None, + ) + + +# ------------------------------------------------------- Hugging Face datasets + + +def _error_detail(resp: httpx.Response) -> str: + try: + body: object = resp.json() + except ValueError: + return resp.text + mapping = jsonshape.as_mapping(body) + if mapping is not None and "error" in mapping: + return str(mapping["error"]) + return resp.text + + +def _checked_payload(resp: httpx.Response, *, dataset: str) -> dict[str, object]: + if resp.status_code in (401, 403): + raise APIError( + f"Hugging Face denied access to '{dataset}' (HTTP {resp.status_code}).", + suggestion="Gated or private dataset? Set HF_TOKEN to a token that has access.", + ) + if resp.status_code == HTTPStatus.NOT_FOUND: + raise UsageError( + f"Hugging Face dataset '{dataset}' was not found: {_error_detail(resp)}", + suggestion="Check the dataset id, e.g. 'distil-whisper/meanwhile'.", + ) + if resp.status_code != HTTPStatus.OK: + raise APIError( + f"Hugging Face datasets server error (HTTP {resp.status_code}): {_error_detail(resp)}" + ) + try: + data: object = resp.json() + except ValueError as exc: + raise APIError("Hugging Face datasets server returned invalid JSON.") from exc + mapping = jsonshape.as_mapping(data) + if mapping is None: + raise APIError( + "Hugging Face datasets server returned unexpected JSON (expected an object)." + ) + return mapping + + +def _fetch_json(endpoint: str, params: dict[str, str | int], *, dataset: str) -> dict[str, object]: + token = os.environ.get("HF_TOKEN") + headers = {"authorization": f"Bearer {token}"} if token else {} + try: + with httpx.Client(base_url=_DATASETS_SERVER, timeout=_TIMEOUT, headers=headers) as client: + resp = client.get(endpoint, params=params) + except httpx.HTTPError as exc: + raise APIError(f"Could not reach the Hugging Face datasets server: {exc}") from exc + return _checked_payload(resp, dataset=dataset) + + +def _split_entries(dataset: str) -> list[dict[str, object]]: + payload = _fetch_json("/splits", {"dataset": dataset}, dataset=dataset) + entries = jsonshape.mapping_list(payload.get("splits")) + if not entries: + raise APIError(f"Hugging Face reports no splits for '{dataset}'.") + return entries + + +def _pick_subset(entries: list[dict[str, object]], subset: str | None, dataset: str) -> str: + configs = list(dict.fromkeys(str(entry.get("config")) for entry in entries)) + if subset is not None: + if subset in configs: + return subset + raise UsageError(f"'{dataset}' has no subset '{subset}' (subsets: {', '.join(configs)}).") + if len(configs) == 1: + return configs[0] + if "default" in configs: + return "default" + raise UsageError( + f"'{dataset}' has multiple subsets: {', '.join(configs)}.", + suggestion="Pick one with --subset.", + ) + + +def _pick_split( + entries: list[dict[str, object]], config: str, split: str | None, dataset: str +) -> str: + splits = [str(entry.get("split")) for entry in entries if str(entry.get("config")) == config] + if split is not None: + if split in splits: + return split + raise UsageError( + f"'{dataset}' has no '{split}' split in subset '{config}' " + f"(splits: {', '.join(splits)})." + ) + if "test" in splits: + return "test" + if len(splits) == 1: + return splits[0] + raise UsageError( + f"'{dataset}' has several splits in subset '{config}': {', '.join(splits)}.", + suggestion="Pick one with --split (eval sets usually score 'test').", + ) + + +def _audio_source(cell: object, *, column: str, item_id: str) -> str: + """The audio URL out of a datasets-server cell: a bare string, or the first + ``src`` of the ``[{"src": …, "type": …}]`` shape audio columns render as.""" + if isinstance(cell, str): + return cell + for source in jsonshape.mapping_list(cell): + src = source.get("src") + if isinstance(src, str): + return src + raise APIError( + f"{item_id}: column '{column}' carries no audio URL.", + suggestion="Point --audio-column at the dataset's audio column.", + ) + + +def _hf_item( + row: dict[str, object], + split_name: str, + *, + audio_col: str, + text_col: str | None, + with_speakers: bool, +) -> EvalItem: + cells = jsonshape.as_mapping(row.get("row")) or {} + item_id = f"{split_name}[{row.get('row_idx')}]" + return EvalItem( + item_id=item_id, + audio=_audio_source(cells.get(audio_col), column=audio_col, item_id=item_id), + reference=_row_reference(cells, text_col, item_id), + turns=_row_turns(cells, item_id) if with_speakers else None, + ) + + +def _load_hf( + dataset: str, + *, + split: str | None, + subset: str | None, + audio_column: str | None, + text_column: str | None, + limit: int, + with_speakers: bool, +) -> EvalDataset: + if not _HF_ID_RE.match(dataset): + raise UsageError( + f"'{dataset}' is neither a local .csv/.jsonl manifest nor a Hugging Face dataset id.", + suggestion="Pass a manifest path, or an id like 'distil-whisper/meanwhile'.", + ) + entries = _split_entries(dataset) + config = _pick_subset(entries, subset, dataset) + split_name = _pick_split(entries, config, split, dataset) + payload = _fetch_json( + "/rows", + {"dataset": dataset, "config": config, "split": split_name, "offset": 0, "length": limit}, + dataset=dataset, + ) + rows = jsonshape.mapping_list(payload.get("rows")) + if not rows: + raise APIError(f"'{dataset}' ({config}/{split_name}) returned no rows.") + audio_col, text_col = _resolve_columns( + list(jsonshape.as_mapping(rows[0].get("row")) or {}), + audio_column=audio_column, + text_column=text_column, + with_speakers=with_speakers, + ) + return EvalDataset( + label=f"{dataset} · {config}/{split_name}", + items=[ + _hf_item( + row, split_name, audio_col=audio_col, text_col=text_col, with_speakers=with_speakers + ) + for row in rows + ], + ) diff --git a/aai_cli/main.py b/aai_cli/main.py index 29290c53..bc67198b 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -26,6 +26,7 @@ deploy, dev, doctor, + evaluate, init, keys, llm, @@ -64,6 +65,7 @@ "agent", "speak", "llm", + "eval", # Setup & Tools — get set up & maintain "doctor", "setup", @@ -319,6 +321,7 @@ def main( app.add_typer(agent.app) app.add_typer(speak.app) app.add_typer(llm.app) +app.add_typer(evaluate.app) # eval app.add_typer(account.app) # balance, usage, limits app.add_typer(login.app) # login, logout, whoami app.add_typer(doctor.app) diff --git a/aai_cli/wer.py b/aai_cli/wer.py new file mode 100644 index 00000000..5aeef893 --- /dev/null +++ b/aai_cli/wer.py @@ -0,0 +1,79 @@ +"""Word error rate (WER) scoring for `assembly eval`, backed by jiwer. + +A thin shim over :mod:`jiwer` — the de-facto standard WER implementation — so +the alignment math is never re-derived here. Texts are normalized the way ASR +benchmarks conventionally are (lowercase, punctuation stripped, whitespace +collapsed) so a transcript isn't penalized for casing or punctuation style. +No SDK, no Rich: the command layer owns all rendering. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import jiwer +from pydantic import TypeAdapter + +# The normalization applied to both reference and hypothesis before alignment. +_TRANSFORM = jiwer.Compose( + [ + jiwer.ToLowerCase(), + jiwer.RemovePunctuation(), + jiwer.RemoveMultipleSpaces(), + jiwer.Strip(), + jiwer.ReduceToListOfListOfWords(), + ] +) + + +# jiwer's transform output is only partially typed; validate the shape instead +# of trusting inference (the project pattern for untyped third-party returns). +_SENTENCES: TypeAdapter[list[list[str]]] = TypeAdapter(list[list[str]]) + + +def normalize_words(text: str) -> list[str]: + """The text as the list of comparison words jiwer will align. + + Exposed so the dataset loader can reject a reference that normalizes to + nothing (e.g. punctuation-only) with the same rule the scorer uses. + """ + return _SENTENCES.validate_python(_TRANSFORM(text))[0] + + +@dataclass(frozen=True) +class Score: + """Edit errors against a reference of ``words`` words; pooled for corpus WER.""" + + errors: int + words: int + + @property + def wer(self) -> float: + return self.errors / self.words + + +def score(reference: str, hypothesis: str) -> Score: + """Score a hypothesis transcript against its reference text. + + The caller guarantees a reference that normalizes to at least one word (the + dataset loader rejects empty-reference rows), so ``Score.wer`` is always + well-defined. + """ + out = jiwer.process_words( + reference, + hypothesis, + reference_transform=_TRANSFORM, + hypothesis_transform=_TRANSFORM, + ) + return Score( + errors=out.substitutions + out.deletions + out.insertions, + words=out.hits + out.substitutions + out.deletions, + ) + + +def pooled(scores: list[Score]) -> Score: + """Corpus-level score: total errors over total reference words (not a mean of rates).""" + return Score( + errors=sum(item.errors for item in scores), + words=sum(item.words for item in scores), + ) diff --git a/pyproject.toml b/pyproject.toml index 0b40be95..58c0c840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ - "typer>=0.26.7", # >=0.13 vendors its own click (typer._click); we no longer import click + "typer>=0.26.7", # >=0.13 vendors its own click (typer._click); we no longer import click "assemblyai>=0.64.4", "rich>=15.0.0", "keyring>=25.7.0", @@ -49,6 +49,11 @@ dependencies = [ "packaging>=24.0", # audioop (used for PCM resampling) left the stdlib in 3.13; this backport provides it. "audioop-lts>=0.2; python_version >= '3.13'", + # `assembly eval` scoring: jiwer for WER, pyannote.metrics for DER — the de-facto + # standard implementations of each. pyannote drags numpy/scipy/pandas, so der.py + # imports it lazily to keep CLI startup fast. + "jiwer>=4.0", + "pyannote-metrics>=4.0", ] [project.urls] diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index cd7c3ab4..c3a472ee 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -233,6 +233,73 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[eval] + ''' + + Usage: assembly eval [OPTIONS] DATASET + + Transcribe an evaluation dataset and score WER against its reference texts. + + Handy for picking a model: run once per --speech-model and compare. Datasets + come from the Hugging Face Hub (gated ones need HF_TOKEN) or a local + .csv/.jsonl + manifest. --speaker-labels also scores diarization (DER) against reference + speaker turns. + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ * dataset TEXT Hugging Face dataset id, or a local .csv/.jsonl │ + │ manifest with audio + text columns. │ + │ [required] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --split TEXT Hugging Face split to │ + │ score (default: test). │ + │ --subset TEXT Hugging Face │ + │ config/subset name (e.g. │ + │ a language). │ + │ --limit INTEGER RANGE Rows to evaluate │ + │ [1<=x<=100] (1-100). │ + │ [default: 10] │ + │ --audio-column TEXT Audio column name │ + │ (default: auto-detect). │ + │ --text-column TEXT Reference text column │ + │ name (default: │ + │ auto-detect). │ + │ --speech-model [best|nano|slam-1|univer Speech model to │ + │ sal] evaluate. │ + │ --language-code TEXT Force a language (e.g. │ + │ en_us). │ + │ --speaker-labels Diarize and also score │ + │ DER against the │ + │ dataset's reference │ + │ speaker turns │ + │ (speakers/timestamps_st… │ + │ columns, in seconds). │ + │ --collar FLOAT RANGE [x>=0.0] DER forgiveness │ + │ (seconds) around each │ + │ reference turn boundary. │ + │ [default: 1.0] │ + │ --json -j Output the rows and │ + │ summary as one JSON │ + │ object. │ + │ --help Show this message and │ + │ exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Score a model on 10 rows of an HF dataset + $ assembly eval distil-whisper/meanwhile + Compare models on your own audio + $ assembly eval calls.csv --speech-model universal + Score diarization too (WER + DER) + $ assembly eval agent-calls.jsonl --speaker-labels + Pick a subset/split and more rows + $ assembly eval mozilla-foundation/common_voice_17_0 --subset en --limit 50 + + + ''' # --- # name: test_command_help_matches_snapshot[init] diff --git a/tests/test_der.py b/tests/test_der.py new file mode 100644 index 00000000..cfcffd69 --- /dev/null +++ b/tests/test_der.py @@ -0,0 +1,73 @@ +"""DER scoring (`aai_cli.der`): pyannote-backed alignment, collar, pooling.""" + +import dataclasses + +import pytest + +from aai_cli import der + + +def _assign(obj, attribute, value): + setattr(obj, attribute, value) + + +def test_turns_and_scores_are_immutable_values(): + with pytest.raises(dataclasses.FrozenInstanceError): + _assign(der.Turn(speaker="a", start=0.0, end=1.0), "speaker", "b") + score = der.DerScore(missed=0.0, false_alarm=0.0, confusion=0.0, total=1.0) + with pytest.raises(dataclasses.FrozenInstanceError): + _assign(score, "total", 2.0) + + +REF = [ + der.Turn(speaker="alice", start=0.0, end=10.0), + der.Turn(speaker="bob", start=10.0, end=20.0), +] +HYP = [der.Turn(speaker="A", start=0.0, end=12.0), der.Turn(speaker="B", start=12.0, end=20.0)] + + +def test_optimal_speaker_mapping_scores_only_the_overlap_error(): + # alice↔A and bob↔B map optimally; the 10-12s stretch is confusion. + score = der.score(REF, HYP) + assert score == der.DerScore(missed=0.0, false_alarm=0.0, confusion=2.0, total=20.0) + assert score.der == 0.1 + + +def test_collar_forgives_boundary_slop(): + # A 4s collar swallows the 10-12s confusion around the 10s boundary. + score = der.score(REF, HYP, collar=4.0) + assert score == der.DerScore(missed=0.0, false_alarm=0.0, confusion=0.0, total=12.0) + assert score.der == 0.0 + + +def test_empty_hypothesis_is_all_missed_detection(): + score = der.score(REF, []) + assert score == der.DerScore(missed=20.0, false_alarm=0.0, confusion=0.0, total=20.0) + assert score.der == 1.0 + + +def test_hypothesis_speech_beyond_the_reference_is_false_alarm(): + # The evaluation extent covers the hypothesis too — speech past the + # reference's end counts as false alarm rather than being cropped away. + score = der.score( + [der.Turn(speaker="a", start=0.0, end=10.0)], + [der.Turn(speaker="A", start=0.0, end=15.0)], + ) + assert score == der.DerScore(missed=0.0, false_alarm=5.0, confusion=0.0, total=10.0) + assert score.der == 0.5 + + +def test_overlapping_turns_by_one_speaker_keep_their_own_tracks(): + turns = [der.Turn(speaker="a", start=0.0, end=10.0), der.Turn(speaker="a", start=5.0, end=15.0)] + assert der.score(turns, turns).der == 0.0 + + +def test_pooled_sums_components(): + total = der.pooled( + [ + der.DerScore(missed=1.0, false_alarm=2.0, confusion=3.0, total=10.0), + der.DerScore(missed=0.0, false_alarm=0.0, confusion=0.0, total=10.0), + ] + ) + assert total == der.DerScore(missed=1.0, false_alarm=2.0, confusion=3.0, total=20.0) + assert total.der == 0.3 diff --git a/tests/test_eval_command.py b/tests/test_eval_command.py new file mode 100644 index 00000000..bcef496c --- /dev/null +++ b/tests/test_eval_command.py @@ -0,0 +1,324 @@ +"""`assembly eval` behavior: WER/DER scoring, rendering, flags, error paths. + +The transcription boundary (`client.transcribe`) is mocked; datasets are real +temp manifests so the command exercises the loader end to end. +""" + +import contextlib +import dataclasses +import json +from types import SimpleNamespace + +import assemblyai as aai +import pytest +from typer.testing import CliRunner + +from aai_cli import config, eval_data +from aai_cli.errors import APIError +from aai_cli.main import app + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def workdir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + +def _auth(): + config.set_api_key("default", "sk_live") + + +def _transcript(text, utterances=None): + return SimpleNamespace(text=text, utterances=utterances) + + +def _write_wer_manifest(tmp_path): + for name in ("a.wav", "b.wav"): + (tmp_path / name).write_bytes(b"fake-audio") + (tmp_path / "manifest.csv").write_text( + "audio,text\na.wav,hello there\nb.wav,goodbye now\n", encoding="utf-8" + ) + + +def _write_speaker_manifest(tmp_path, *, with_text=False): + (tmp_path / "a.wav").write_bytes(b"fake-audio") + row = { + "audio": "a.wav", + "speakers": ["alice", "bob"], + "timestamps_start": [0.0, 10.0], + "timestamps_end": [10.0, 20.0], + } + if with_text: + row["text"] = "hello there" + (tmp_path / "manifest.jsonl").write_text(json.dumps(row), encoding="utf-8") + + +def _diarized_utterances(): + # Against the manifest's alice 0-10s / bob 10-20s reference: 2s of confusion + # around the 10s boundary. The default 1s collar forgives 10-10.5s and trims + # 2s of boundary speech from the 20s total -> DER 1.5/18 (see test_der.py). + return [ + SimpleNamespace(speaker="A", start=0, end=12000), + SimpleNamespace(speaker="B", start=12000, end=20000), + ] + + +def _mock_transcribe(mocker, results): + return mocker.patch( + "aai_cli.commands.evaluate.client.transcribe", + autospec=True, + side_effect=list(results), + ) + + +def _payload_of(result): + return next( + json.loads(line) for line in result.output.splitlines() if line.startswith('{"dataset"') + ) + + +def test_wer_table_with_per_file_and_pooled_scores(tmp_path, mocker): + _auth() + _write_wer_manifest(tmp_path) + # File 1 is perfect; file 2 gets 1 of its 2 words wrong -> 50%; pooled 1/4 -> 25%. + _mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye cow")]) + result = runner.invoke(app, ["eval", "manifest.csv"]) + assert result.exit_code == 0 + assert "WER" in result.output + assert "0.00%" in result.output + assert "50.00%" in result.output + assert "25.00%" in result.output + assert "1 error / 4 words" in result.output # pooled errors (and singular grammar) + assert "1 errors" not in result.output + assert "manifest.csv" in result.output + assert "default model" in result.output + assert "DER" not in result.output + + +def test_summary_pluralizes_errors(tmp_path, mocker): + _auth() + _write_wer_manifest(tmp_path) + # "bye cow" vs "goodbye now": two substitutions. + _mock_transcribe(mocker, [_transcript("hello there"), _transcript("bye cow")]) + result = runner.invoke(app, ["eval", "manifest.csv"]) + assert "2 errors / 4 words" in result.output + + +def test_json_payload_shape(tmp_path, mocker): + _auth() + _write_wer_manifest(tmp_path) + _mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye cow")]) + result = runner.invoke(app, ["eval", "manifest.csv", "--json"]) + assert result.exit_code == 0 + payload = _payload_of(result) + assert payload["dataset"] == "manifest.csv" + assert payload["speech_model"] is None + assert payload["items"] == 2 + assert payload["words"] == 4 + assert payload["errors"] == 1 + assert payload["wer"] == 0.25 + assert "der" not in payload + assert payload["rows"][0] == {"item": "a.wav", "words": 2, "errors": 0, "wer": 0.0} + assert payload["rows"][1] == {"item": "b.wav", "words": 2, "errors": 1, "wer": 0.5} + + +def test_speech_model_flag_reaches_config_and_output(tmp_path, mocker): + _auth() + _write_wer_manifest(tmp_path) + tx = _mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye now")]) + result = runner.invoke(app, ["eval", "manifest.csv", "--speech-model", "universal", "--json"]) + assert result.exit_code == 0 + assert _payload_of(result)["speech_model"] == "universal" + tx_config = tx.call_args.kwargs["config"] + assert tx_config.speech_model == aai.SpeechModel.universal + assert tx_config.speaker_labels is None # not requested -> omitted, not False + + +def test_speech_model_named_in_human_header(tmp_path, mocker): + _auth() + _write_wer_manifest(tmp_path) + _mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye now")]) + result = runner.invoke(app, ["eval", "manifest.csv", "--speech-model", "universal"]) + assert "universal" in result.output + assert "default model" not in result.output + + +def test_speaker_labels_scores_der_only_when_dataset_has_no_text(tmp_path, mocker): + _auth() + _write_speaker_manifest(tmp_path) + tx = _mock_transcribe(mocker, [_transcript("ignored", _diarized_utterances())]) + result = runner.invoke(app, ["eval", "manifest.jsonl", "--speaker-labels"]) + assert result.exit_code == 0 + assert tx.call_args.kwargs["config"].speaker_labels is True + assert "DER" in result.output + assert "8.33%" in result.output # 1.5/18 under the default 1s collar + assert "WER" not in result.output + assert "WORDS" not in result.output + + +def test_speaker_labels_with_text_scores_both_metrics(tmp_path, mocker): + _auth() + _write_speaker_manifest(tmp_path, with_text=True) + _mock_transcribe(mocker, [_transcript("hello there", _diarized_utterances())]) + result = runner.invoke(app, ["eval", "manifest.jsonl", "--speaker-labels", "--json"]) + assert result.exit_code == 0 + payload = _payload_of(result) + assert payload["wer"] == 0.0 + assert payload["der"] == 1.5 / 18 # default 1s collar + assert payload["rows"][0]["wer"] == 0.0 + assert payload["rows"][0]["der"] == 1.5 / 18 + + +def test_both_metrics_render_in_one_table(tmp_path, mocker): + _auth() + _write_speaker_manifest(tmp_path, with_text=True) + _mock_transcribe(mocker, [_transcript("hello there", _diarized_utterances())]) + result = runner.invoke(app, ["eval", "manifest.jsonl", "--speaker-labels"]) + assert "WER" in result.output + assert "DER" in result.output + assert "8.33%" in result.output + + +def test_der_counts_leading_speech_the_model_missed(tmp_path, mocker): + _auth() + (tmp_path / "a.wav").write_bytes(b"fake-audio") + row = { + "audio": "a.wav", + "speakers": ["alice"], + "timestamps_start": [0.0], + "timestamps_end": [10.0], + } + (tmp_path / "m.jsonl").write_text(json.dumps(row), encoding="utf-8") + # The hypothesis starts 1000ms in (1.0s exactly after ms→s conversion): + # 1s missed over 10s of reference speech, scored without collar forgiveness. + utterances = [SimpleNamespace(speaker="A", start=1000, end=10000)] + _mock_transcribe(mocker, [_transcript("x", utterances)]) + result = runner.invoke(app, ["eval", "m.jsonl", "--speaker-labels", "--collar", "0", "--json"]) + assert _payload_of(result)["der"] == 0.1 + + +def _assign(obj, attribute, value): + setattr(obj, attribute, value) + + +def test_item_results_are_immutable(): + from aai_cli.commands.evaluate import _ItemResult + + result = _ItemResult(row={}, words=None, speakers=None) + with pytest.raises(dataclasses.FrozenInstanceError): + _assign(result, "words", None) + + +def test_collar_flag_loosens_der(tmp_path, mocker): + _auth() + _write_speaker_manifest(tmp_path) + _mock_transcribe(mocker, [_transcript("ignored", _diarized_utterances())]) + result = runner.invoke( + app, ["eval", "manifest.jsonl", "--speaker-labels", "--collar", "4.0", "--json"] + ) + assert _payload_of(result)["der"] == 0.0 + + +def test_missing_transcript_text_scores_as_all_deletions(tmp_path, mocker): + _auth() + (tmp_path / "a.wav").write_bytes(b"fake-audio") + (tmp_path / "m.csv").write_text("audio,text\na.wav,hello there\n", encoding="utf-8") + _mock_transcribe(mocker, [_transcript(None)]) + result = runner.invoke(app, ["eval", "m.csv", "--json"]) + assert _payload_of(result)["wer"] == 1.0 + + +def _loaded_dataset(): + item = eval_data.EvalItem(item_id="a", audio="https://x/a.wav", reference="hello") + return eval_data.EvalDataset(label="x", items=[item]) + + +def test_loader_defaults(tmp_path, mocker): + _auth() + load = mocker.patch( + "aai_cli.commands.evaluate.eval_data.load", autospec=True, return_value=_loaded_dataset() + ) + _mock_transcribe(mocker, [_transcript("hello")]) + result = runner.invoke(app, ["eval", "org/ds"]) + assert result.exit_code == 0 + kwargs = load.call_args.kwargs + assert kwargs["limit"] == 10 + assert kwargs["with_speakers"] is False + assert kwargs["split"] is None and kwargs["subset"] is None + assert kwargs["audio_column"] is None and kwargs["text_column"] is None + + +def test_explicit_loader_flags_pass_through(tmp_path, mocker): + _auth() + load = mocker.patch( + "aai_cli.commands.evaluate.eval_data.load", autospec=True, return_value=_loaded_dataset() + ) + _mock_transcribe(mocker, [_transcript("hello")]) + argv = [ + "eval", "org/ds", "--limit", "7", "--split", "validation", "--subset", "clean", + "--audio-column", "wav", "--text-column", "ref", + ] # fmt: skip + assert runner.invoke(app, argv).exit_code == 0 + kwargs = load.call_args.kwargs + assert kwargs["limit"] == 7 + assert kwargs["split"] == "validation" + assert kwargs["subset"] == "clean" + assert kwargs["audio_column"] == "wav" + assert kwargs["text_column"] == "ref" + + +@pytest.mark.parametrize("limit", ["0", "101"]) +def test_limit_out_of_range_is_a_usage_error(limit): + result = runner.invoke(app, ["eval", "org/ds", "--limit", limit]) + assert result.exit_code == 2 + assert "1<=x<=100" in result.output.replace(" ", "") + + +@pytest.mark.parametrize("limit", ["1", "100"]) +def test_limit_bounds_are_inclusive(tmp_path, mocker, limit): + _auth() + mocker.patch( + "aai_cli.commands.evaluate.eval_data.load", autospec=True, return_value=_loaded_dataset() + ) + _mock_transcribe(mocker, [_transcript("hello")]) + assert runner.invoke(app, ["eval", "org/ds", "--limit", limit]).exit_code == 0 + + +def test_progress_status_counts_items(tmp_path, mocker, monkeypatch): + _auth() + _write_wer_manifest(tmp_path) + _mock_transcribe(mocker, [_transcript("hello there"), _transcript("goodbye now")]) + seen = [] + + @contextlib.contextmanager + def fake_status(message, *, json_mode, quiet): + seen.append(message) + yield + + monkeypatch.setattr("aai_cli.commands.evaluate.output.status", fake_status) + assert runner.invoke(app, ["eval", "manifest.csv"]).exit_code == 0 + assert seen == ["[1/2] Transcribing a.wav…", "[2/2] Transcribing b.wav…"] + + +def test_api_error_mid_run_fails_cleanly(tmp_path, mocker): + _auth() + _write_wer_manifest(tmp_path) + _mock_transcribe(mocker, [_transcript("hello there"), APIError("rate limited")]) + result = runner.invoke(app, ["eval", "manifest.csv"]) + assert result.exit_code == 1 + assert "rate limited" in result.output + + +def test_missing_manifest_is_a_usage_failure(tmp_path): + _auth() + result = runner.invoke(app, ["eval", "nope.csv"]) + assert result.exit_code == 2 + assert "Manifest not found" in result.output + + +def test_unauthenticated_exits_with_auth_code(tmp_path): + (tmp_path / "a.wav").write_bytes(b"fake-audio") + (tmp_path / "m.csv").write_text("audio,text\na.wav,hello\n", encoding="utf-8") + result = runner.invoke(app, ["eval", "m.csv"]) + assert result.exit_code == 4 diff --git a/tests/test_eval_data_hf.py b/tests/test_eval_data_hf.py new file mode 100644 index 00000000..72101cae --- /dev/null +++ b/tests/test_eval_data_hf.py @@ -0,0 +1,274 @@ +"""Hugging Face dataset loading for `assembly eval` (`aai_cli.eval_data`). + +Runs against an httpx MockTransport (the test_auth_ams.py pattern), so +pytest-socket stays armed; local-manifest paths live in +test_eval_data_manifest.py. +""" + +import httpx2 as httpx +import pytest + +from aai_cli import der, eval_data +from aai_cli.errors import APIError, UsageError + +# ------------------------------------------------------- Hugging Face datasets + + +def _patch_transport(monkeypatch, handler): + real_client = httpx.Client + + def fake_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + monkeypatch.setattr(eval_data.httpx, "Client", fake_client) + + +def _audio_cell(url="https://hf.example/audio/0.wav"): + return [{"src": url, "type": "audio/wav"}] + + +def _hf_handler(monkeypatch, *, splits, rows, seen=None): + def handler(request: httpx.Request) -> httpx.Response: + if seen is not None: + seen.append(request) + if request.url.path == "/splits": + return httpx.Response(200, json={"splits": splits}) + return httpx.Response(200, json={"rows": rows}) + + _patch_transport(monkeypatch, handler) + + +_ONE_SPLIT = [{"dataset": "org/ds", "config": "default", "split": "test"}] + + +def _hf_row(idx=0, **cells): + cells.setdefault("audio", _audio_cell()) + cells.setdefault("text", "hello world") + return {"row_idx": idx, "row": cells, "truncated_cells": []} + + +def test_hf_happy_path_resolves_columns_and_requests(monkeypatch): + seen = [] + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row()], seen=seen) + data = eval_data.load("org/ds", limit=5) + assert data.label == "org/ds · default/test" + item = data.items[0] + assert item.item_id == "test[0]" + assert item.audio == "https://hf.example/audio/0.wav" + assert item.reference == "hello world" + splits_req, rows_req = seen + assert splits_req.url.path == "/splits" + assert splits_req.url.params["dataset"] == "org/ds" + assert "authorization" not in splits_req.headers + params = rows_req.url.params + assert params["config"] == "default" + assert params["split"] == "test" + assert params["offset"] == "0" + assert params["length"] == "5" + + +def test_hf_token_sent_when_set(monkeypatch): + seen = [] + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row()], seen=seen) + monkeypatch.setenv("HF_TOKEN", "hf_secret") + eval_data.load("org/ds", limit=1) + assert seen[0].headers["authorization"] == "Bearer hf_secret" + + +def test_hf_bare_string_audio_cell(monkeypatch): + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row(audio="https://hf.example/a.mp3")]) + assert eval_data.load("org/ds", limit=1).items[0].audio == "https://hf.example/a.mp3" + + +def test_hf_audio_cell_skips_sources_without_src(monkeypatch): + cell = [{"type": "audio/wav"}, {"src": "https://hf.example/b.wav"}] + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row(audio=cell)]) + assert eval_data.load("org/ds", limit=1).items[0].audio == "https://hf.example/b.wav" + + +def test_hf_unusable_audio_cell_errors(monkeypatch): + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row(audio=123)]) + with pytest.raises(APIError) as exc: + eval_data.load("org/ds", limit=1) + assert "test[0]" in exc.value.message and "no audio URL" in exc.value.message + + +def test_hf_speaker_rows(monkeypatch): + row = _hf_row(speakers=["alice"], timestamps_start=[0.5], timestamps_end=[2.0]) + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[row]) + data = eval_data.load("org/ds", limit=1, with_speakers=True) + assert data.items[0].turns == [der.Turn(speaker="alice", start=0.5, end=2.0)] + + +def test_hf_single_named_config_auto_picked(monkeypatch): + seen = [] + splits = [{"config": "clean", "split": "test"}] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()], seen=seen) + assert eval_data.load("org/ds", limit=1).label == "org/ds · clean/test" + assert seen[1].url.params["config"] == "clean" + + +def test_hf_default_config_wins_among_many(monkeypatch): + splits = [ + {"config": "clean", "split": "test"}, + {"config": "default", "split": "test"}, + ] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()]) + assert eval_data.load("org/ds", limit=1).label == "org/ds · default/test" + + +def test_hf_many_configs_without_default_require_subset(monkeypatch): + splits = [{"config": "clean", "split": "test"}, {"config": "other", "split": "test"}] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()]) + with pytest.raises(UsageError) as exc: + eval_data.load("org/ds", limit=1) + assert "clean, other" in exc.value.message + assert exc.value.suggestion is not None and "--subset" in exc.value.suggestion + + +def test_hf_explicit_subset(monkeypatch): + seen = [] + splits = [{"config": "clean", "split": "test"}, {"config": "other", "split": "test"}] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()], seen=seen) + eval_data.load("org/ds", subset="other", limit=1) + assert seen[1].url.params["config"] == "other" + + +def test_hf_unknown_subset_lists_options(monkeypatch): + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row()]) + with pytest.raises(UsageError, match="no subset 'nope'"): + eval_data.load("org/ds", subset="nope", limit=1) + + +def test_hf_test_split_preferred(monkeypatch): + seen = [] + splits = [ + {"config": "default", "split": "train"}, + {"config": "default", "split": "test"}, + ] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()], seen=seen) + eval_data.load("org/ds", limit=1) + assert seen[1].url.params["split"] == "test" + + +def test_hf_single_split_used_when_no_test(monkeypatch): + splits = [{"config": "default", "split": "validation"}] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()]) + assert eval_data.load("org/ds", limit=1).label == "org/ds · default/validation" + + +def test_hf_many_splits_without_test_require_split(monkeypatch): + splits = [ + {"config": "default", "split": "train"}, + {"config": "default", "split": "validation"}, + ] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()]) + with pytest.raises(UsageError) as exc: + eval_data.load("org/ds", limit=1) + assert "train, validation" in exc.value.message + assert exc.value.suggestion is not None and "--split" in exc.value.suggestion + + +def test_hf_explicit_split_must_exist_for_the_subset(monkeypatch): + # 'train' exists, but only under the other config — split filtering is per-config. + splits = [ + {"config": "default", "split": "test"}, + {"config": "other", "split": "train"}, + ] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()]) + with pytest.raises(UsageError, match="no 'train' split in subset 'default'"): + eval_data.load("org/ds", split="train", limit=1) + + +def test_hf_explicit_split_used(monkeypatch): + seen = [] + splits = [ + {"config": "default", "split": "train"}, + {"config": "default", "split": "test"}, + ] + _hf_handler(monkeypatch, splits=splits, rows=[_hf_row()], seen=seen) + eval_data.load("org/ds", split="train", limit=1) + assert seen[1].url.params["split"] == "train" + + +def test_hf_empty_splits_payload(monkeypatch): + _hf_handler(monkeypatch, splits=[], rows=[]) + with pytest.raises(APIError, match="no splits"): + eval_data.load("org/ds", limit=1) + + +def test_hf_no_rows(monkeypatch): + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[]) + with pytest.raises(APIError, match="returned no rows"): + eval_data.load("org/ds", limit=1) + + +@pytest.mark.parametrize("status", [401, 403]) +def test_hf_auth_failure_suggests_hf_token(monkeypatch, status): + _patch_transport(monkeypatch, lambda request: httpx.Response(status, json={"error": "gated"})) + with pytest.raises(APIError) as exc: + eval_data.load("org/gated", limit=1) + assert str(status) in exc.value.message + assert exc.value.suggestion is not None and "HF_TOKEN" in exc.value.suggestion + + +def test_hf_404_is_a_usage_error_with_detail(monkeypatch): + _patch_transport( + monkeypatch, lambda request: httpx.Response(404, json={"error": "Dataset not on the Hub"}) + ) + with pytest.raises(UsageError) as exc: + eval_data.load("org/nope", limit=1) + assert "org/nope" in exc.value.message + assert "Dataset not on the Hub" in exc.value.message + # The detail is extracted from the JSON body, not the raw body pasted in. + assert '"error"' not in exc.value.message + + +def test_hf_server_error_with_plain_text_body(monkeypatch): + _patch_transport(monkeypatch, lambda request: httpx.Response(500, text="upstream down")) + with pytest.raises(APIError) as exc: + eval_data.load("org/ds", limit=1) + assert "500" in exc.value.message and "upstream down" in exc.value.message + + +def test_hf_server_error_with_non_object_json_falls_back_to_text(monkeypatch): + _patch_transport(monkeypatch, lambda request: httpx.Response(500, json=["weird"])) + with pytest.raises(APIError) as exc: + eval_data.load("org/ds", limit=1) + assert '["weird"]' in exc.value.message + + +def test_hf_invalid_json_success_body(monkeypatch): + _patch_transport(monkeypatch, lambda request: httpx.Response(200, text="")) + with pytest.raises(APIError, match="invalid JSON"): + eval_data.load("org/ds", limit=1) + + +def test_hf_non_object_json_success_body(monkeypatch): + _patch_transport(monkeypatch, lambda request: httpx.Response(200, json=["nope"])) + with pytest.raises(APIError, match="expected an object"): + eval_data.load("org/ds", limit=1) + + +def test_hf_network_failure_wrapped(monkeypatch): + def handler(request): + raise httpx.ConnectError("boom") + + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError, match="Could not reach the Hugging Face datasets server"): + eval_data.load("org/ds", limit=1) + + +@pytest.mark.parametrize("bad_id", ["not a dataset", "a/b/c", "org/ds?x=1"]) +def test_non_hf_looking_ids_rejected_before_any_request(bad_id): + # No transport patch: pytest-socket would fail loudly if a request were made. + with pytest.raises(UsageError, match="neither a local"): + eval_data.load(bad_id, limit=1) + + +def test_hf_empty_reference_text_rejected(monkeypatch): + _hf_handler(monkeypatch, splits=_ONE_SPLIT, rows=[_hf_row(text="…")]) + with pytest.raises(UsageError) as exc: + eval_data.load("org/ds", limit=1) + assert "test[0]" in exc.value.message and "empty reference" in exc.value.message diff --git a/tests/test_eval_data_manifest.py b/tests/test_eval_data_manifest.py new file mode 100644 index 00000000..a83260cb --- /dev/null +++ b/tests/test_eval_data_manifest.py @@ -0,0 +1,259 @@ +"""Local-manifest loading for `assembly eval` (`aai_cli.eval_data`). + +Runs against real temp files; the Hugging Face paths live in +test_eval_data_hf.py. +""" + +import dataclasses +import json + +import pytest + +from aai_cli import der, eval_data +from aai_cli.errors import CLIError, UsageError + +# ---------------------------------------------------------------- local manifests + + +def _assign(obj, attribute, value): + setattr(obj, attribute, value) + + +def test_loaded_dataset_and_items_are_immutable(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\na.wav,hello\n", encoding="utf-8") + data = eval_data.load(str(manifest), limit=10) + with pytest.raises(dataclasses.FrozenInstanceError): + _assign(data, "label", "x") + with pytest.raises(dataclasses.FrozenInstanceError): + _assign(data.items[0], "reference", "x") + + +def _write_audio(directory, *names): + for name in names: + (directory / name).write_bytes(b"fake-audio") + + +def _write_jsonl(path, rows): + path.write_text("\n".join(json.dumps(row) for row in rows), encoding="utf-8") + + +def test_csv_manifest_loads_items(tmp_path): + _write_audio(tmp_path, "a.wav", "b.wav") + manifest = tmp_path / "manifest.csv" + manifest.write_text("audio,text\na.wav,hello there\nb.wav,goodbye now\n", encoding="utf-8") + data = eval_data.load(str(manifest), limit=10) + assert data.label == "manifest.csv" + assert [item.item_id for item in data.items] == ["a.wav", "b.wav"] + assert data.items[0].audio == str(tmp_path / "a.wav") + assert data.items[0].reference == "hello there" + assert data.items[0].turns is None + + +def test_manifest_respects_limit(tmp_path): + _write_audio(tmp_path, "a.wav", "b.wav", "c.wav") + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\na.wav,x\nb.wav,y\nc.wav,z\n", encoding="utf-8") + data = eval_data.load(str(manifest), limit=2) + assert [item.item_id for item in data.items] == ["a.wav", "b.wav"] + + +def test_jsonl_manifest_with_url_audio_and_nemo_columns(tmp_path): + manifest = tmp_path / "m.jsonl" + _write_jsonl( + manifest, + [{"audio_filepath": "https://cdn.example/a.mp3", "transcript": "hello world"}], + ) + data = eval_data.load(str(manifest), limit=10) + # URLs pass through untouched (no local-file check) and NeMo-style column + # names auto-detect. + assert data.items[0].audio == "https://cdn.example/a.mp3" + assert data.items[0].item_id == "a.mp3" + assert data.items[0].reference == "hello world" + + +def test_jsonl_manifest_skips_blank_lines(tmp_path): + _write_audio(tmp_path, "a.wav", "b.wav") + manifest = tmp_path / "m.jsonl" + manifest.write_text( + '{"audio": "a.wav", "text": "x"}\n\n{"audio": "b.wav", "text": "y"}\n', encoding="utf-8" + ) + assert len(eval_data.load(str(manifest), limit=10).items) == 2 + + +def test_manifest_missing_file_errors_before_any_network(tmp_path): + with pytest.raises(CLIError) as exc: + eval_data.load(str(tmp_path / "missing.csv"), limit=10) + assert exc.value.error_type == "file_not_found" + assert exc.value.exit_code == 2 + assert "missing.csv" in exc.value.message + + +def test_manifest_audio_file_missing_names_resolved_path(tmp_path): + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\nnope.wav,hello\n", encoding="utf-8") + with pytest.raises(CLIError) as exc: + eval_data.load(str(manifest), limit=10) + assert exc.value.error_type == "file_not_found" + assert exc.value.exit_code == 2 + assert str(tmp_path / "nope.wav") in exc.value.message + assert exc.value.suggestion is not None and str(tmp_path) in exc.value.suggestion + + +def test_manifest_row_without_audio_value_reports_row_number(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\na.wav,hello\n,world\n", encoding="utf-8") + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), limit=10) + assert "row 2" in exc.value.message + + +def test_manifest_with_no_recognized_text_column_suggests_flag(tmp_path): + manifest = tmp_path / "m.csv" + manifest.write_text("audio,ref\na.wav,hello\n", encoding="utf-8") + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), limit=10) + # The message names the conventional column (the first auto-detect candidate). + assert "Could not find a text column" in exc.value.message + assert "audio, ref" in exc.value.message + assert exc.value.suggestion is not None and "--text-column" in exc.value.suggestion + + +def test_manifest_explicit_text_column_is_used(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.csv" + manifest.write_text("audio,ref\na.wav,hello there\n", encoding="utf-8") + data = eval_data.load(str(manifest), text_column="ref", limit=10) + assert data.items[0].reference == "hello there" + + +def test_manifest_explicit_text_column_missing_errors(tmp_path): + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\na.wav,hello\n", encoding="utf-8") + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), text_column="ref", limit=10) + assert "'ref'" in exc.value.message + + +def test_manifest_empty_reference_rejected(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\na.wav,...\n", encoding="utf-8") + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), limit=10) + assert "empty reference" in exc.value.message + + +def test_manifest_with_no_rows_errors(tmp_path): + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\n", encoding="utf-8") + with pytest.raises(UsageError, match="no rows"): + eval_data.load(str(manifest), limit=10) + + +def test_jsonl_invalid_json_line_reports_line_number(tmp_path): + manifest = tmp_path / "m.jsonl" + manifest.write_text('{"audio": "a.wav", "text": "x"}\nnot-json\n', encoding="utf-8") + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), limit=10) + assert "line 2" in exc.value.message + + +def test_jsonl_non_object_line_rejected(tmp_path): + manifest = tmp_path / "m.jsonl" + manifest.write_text('["audio", "text"]\n', encoding="utf-8") + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), limit=10) + assert "line 1 is not a JSON object" in exc.value.message + + +def test_split_and_subset_rejected_for_manifests(tmp_path): + manifest = tmp_path / "m.csv" + manifest.write_text("audio,text\na.wav,hello\n", encoding="utf-8") + with pytest.raises(UsageError, match="local manifests"): + eval_data.load(str(manifest), split="test", limit=10) + with pytest.raises(UsageError, match="local manifests"): + eval_data.load(str(manifest), subset="default", limit=10) + + +# ------------------------------------------------- manifests with speaker turns + + +def _speaker_row(audio, **extra): + return { + "audio": audio, + "speakers": ["alice", "bob"], + "timestamps_start": [0.0, 10.0], + "timestamps_end": [10.0, 20.0], + **extra, + } + + +def test_speaker_manifest_loads_turns_without_text(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.jsonl" + _write_jsonl(manifest, [_speaker_row("a.wav")]) + data = eval_data.load(str(manifest), limit=10, with_speakers=True) + assert data.items[0].reference is None + assert data.items[0].turns == [ + der.Turn(speaker="alice", start=0.0, end=10.0), + der.Turn(speaker="bob", start=10.0, end=20.0), + ] + + +def test_speaker_manifest_with_text_scores_both(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.jsonl" + _write_jsonl(manifest, [_speaker_row("a.wav", text="hello world")]) + data = eval_data.load(str(manifest), limit=10, with_speakers=True) + assert data.items[0].reference == "hello world" + assert data.items[0].turns is not None + + +def test_speaker_manifest_with_explicit_text_column(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.jsonl" + _write_jsonl(manifest, [_speaker_row("a.wav", ref="hello world")]) + data = eval_data.load(str(manifest), text_column="ref", limit=10, with_speakers=True) + assert data.items[0].reference == "hello world" + + +def test_speakers_requested_but_columns_missing(tmp_path): + manifest = tmp_path / "m.jsonl" + _write_jsonl(manifest, [{"audio": "a.wav", "text": "x", "speakers": ["a"]}]) + with pytest.raises(UsageError) as exc: + eval_data.load(str(manifest), limit=10, with_speakers=True) + assert "missing: timestamps_start, timestamps_end" in exc.value.message + assert exc.value.suggestion is not None and ".jsonl" in exc.value.suggestion + + +def test_speaker_arrays_of_unequal_length_rejected(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.jsonl" + row = _speaker_row("a.wav") + row["timestamps_end"] = [10.0] + _write_jsonl(manifest, [row]) + with pytest.raises(UsageError, match="equal-length"): + eval_data.load(str(manifest), limit=10, with_speakers=True) + + +def test_empty_speaker_arrays_rejected(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.jsonl" + row = {"audio": "a.wav", "speakers": [], "timestamps_start": [], "timestamps_end": []} + _write_jsonl(manifest, [row]) + with pytest.raises(UsageError, match="non-empty"): + eval_data.load(str(manifest), limit=10, with_speakers=True) + + +def test_csv_cells_cannot_hold_speaker_arrays(tmp_path): + _write_audio(tmp_path, "a.wav") + manifest = tmp_path / "m.csv" + manifest.write_text( + 'audio,speakers,timestamps_start,timestamps_end\na.wav,"[""alice""]","[0.0]","[10.0]"\n', + encoding="utf-8", + ) + with pytest.raises(UsageError, match=r"equal-length|non-empty"): + eval_data.load(str(manifest), limit=10, with_speakers=True) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 510f8449..a4faff8a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -106,6 +106,7 @@ def test_help_lists_commands_in_workflow_order(): "agent", "speak", "llm", + "eval", # Setup & Tools "doctor", "setup", diff --git a/tests/test_wer.py b/tests/test_wer.py new file mode 100644 index 00000000..a39230f7 --- /dev/null +++ b/tests/test_wer.py @@ -0,0 +1,64 @@ +"""WER scoring (`aai_cli.wer`): normalization, alignment counts, pooling.""" + +import dataclasses + +import pytest + +from aai_cli import wer + + +def _assign(obj, attribute, value): + setattr(obj, attribute, value) + + +def test_score_is_an_immutable_value(): + with pytest.raises(dataclasses.FrozenInstanceError): + _assign(wer.Score(errors=0, words=1), "errors", 1) + + +def test_normalize_lowercases_and_strips_punctuation(): + assert wer.normalize_words("Hello, WORLD! It's fine.") == ["hello", "world", "its", "fine"] + + +def test_normalize_empty_and_punctuation_only_yield_no_words(): + assert wer.normalize_words("") == [] + assert wer.normalize_words("...!!!") == [] + + +def test_identical_texts_score_zero(): + score = wer.score("the quick brown fox", "the quick brown fox") + assert score == wer.Score(errors=0, words=4) + assert score.wer == 0.0 + + +def test_normalization_differences_do_not_count_as_errors(): + assert wer.score("Hello, world.", "hello world") == wer.Score(errors=0, words=2) + + +def test_single_substitution(): + score = wer.score("a b c d", "a x c d") + assert score == wer.Score(errors=1, words=4) + assert score.wer == 0.25 + + +def test_single_deletion(): + assert wer.score("a b c", "a c") == wer.Score(errors=1, words=3) + + +def test_insertions_count_against_reference_length(): + # Two inserted words; the reference length (the denominator) stays 3, so + # WER can exceed what a substitution-only alignment would give. + score = wer.score("a b c", "x a b c y") + assert score == wer.Score(errors=2, words=3) + + +def test_empty_hypothesis_is_all_deletions(): + score = wer.score("a b c", "") + assert score == wer.Score(errors=3, words=3) + assert score.wer == 1.0 + + +def test_pooled_sums_errors_and_words(): + total = wer.pooled([wer.Score(errors=1, words=4), wer.Score(errors=2, words=6)]) + assert total == wer.Score(errors=3, words=10) + assert total.wer == 0.3 diff --git a/uv.lock b/uv.lock index c3352120..9973a051 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.15'", - "python_full_version == '3.14.*'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -16,10 +24,12 @@ dependencies = [ { name = "assemblyai" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "httpx2" }, + { name = "jiwer" }, { name = "keyring" }, { name = "openai" }, { name = "packaging" }, { name = "platformdirs" }, + { name = "pyannote-metrics" }, { name = "pydantic" }, { name = "questionary" }, { name = "rich" }, @@ -65,10 +75,12 @@ requires-dist = [ { name = "assemblyai", specifier = ">=0.64.4" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, { name = "httpx2", specifier = ">=2.0.0" }, + { name = "jiwer", specifier = ">=4.0" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "openai", specifier = ">=2.41.0" }, { name = "packaging", specifier = ">=24.0" }, { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "pyannote-metrics", specifier = ">=4.0" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "questionary", specifier = ">=2.0.1" }, { name = "rich", specifier = ">=15.0.0" }, @@ -541,7 +553,7 @@ name = "cryptography" version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } wheels = [ @@ -984,6 +996,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, ] +[[package]] +name = "jiwer" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rapidfuzz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/1e/963dfc249d5bbe88079b57a22556e981ddd9208e4b6116e48bdbfc01f26b/jiwer-4.0.0.tar.gz", hash = "sha256:ae9c051469102a61ef0927100baeeb4546f78d180c9b0948281d08eaf44c191e", size = 28074, upload-time = "2025-06-19T16:05:23.004Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/c9/172c525330c739a068c01050759a6f855ce16212db10a0359e690a03ac48/jiwer-4.0.0-py3-none-any.whl", hash = "sha256:7efaf0bd336b095d99ddef9dd67e1ee829d75d58aa2a81d9639870b01d6d95ea", size = 23034, upload-time = "2025-06-19T16:05:21.821Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -1219,6 +1253,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -1228,6 +1271,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + [[package]] name = "openai" version = "2.41.0" @@ -1256,6 +1360,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -1311,6 +1467,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "pyannote-core" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/be/4a35ea31c685aef801f7f35c193e7766ca1bb948ae497a625cbfaa8c31ba/pyannote_core-6.0.1.tar.gz", hash = "sha256:4b4ada3276f6df4e073fa79166636e3597d0dcb5a0fe26014a3477867cc033fb", size = 327540, upload-time = "2025-09-16T09:24:39.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/57/ecf62344b9b81debd0ca95ed987135e93d1b039507f8174f52d1d19d8c6b/pyannote_core-6.0.1-py3-none-any.whl", hash = "sha256:924550d6ecf6b05ad13bf3f66f59c29fc740cf1c62a6fca860ac2e66908203e5", size = 57505, upload-time = "2025-09-16T09:24:37.798Z" }, +] + +[[package]] +name = "pyannote-database" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pandas" }, + { name = "pyannote-core" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/45/6210274c187cc457e854be8b56c6819fa14376f27e7e2b6021b2aa02449a/pyannote_database-6.1.1.tar.gz", hash = "sha256:bbe76da738257a9e64061123d9694ad7e949c4f171d91a9269606d873528cd10", size = 112225, upload-time = "2025-12-07T06:33:10.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/bf/6a6f5abaa4d9f803f34c9883ef5e316624eac6be0eaa87720216be9bba12/pyannote_database-6.1.1-py3-none-any.whl", hash = "sha256:36460c70ce9f50ff25c9ea365bc83ad625bb6b2494deccf6bd3fc750686ae684", size = 53735, upload-time = "2025-12-07T06:33:11.578Z" }, +] + +[[package]] +name = "pyannote-metrics" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "pyannote-core" }, + { name = "pyannote-database" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/ba/7dbc2f790d5e321e46dc1e250ff00b69cdefc0c5695b811e96a4b932cddd/pyannote_metrics-4.1.tar.gz", hash = "sha256:afe24c54ee0799e8cfbe8ee85fa517793c3450bb7eae8fedd1a77ccec0343f7e", size = 883419, upload-time = "2026-05-06T16:33:10.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/d3/9cd1df66aa9be47a36df46f007e198bbdec3ddebc1bd8833c7a9e8a554a9/pyannote_metrics-4.1-py3-none-any.whl", hash = "sha256:34a54b7671f61709c1865d0484843e5b46ea3c4e4e5260ab065e5b3156c733d3", size = 52116, upload-time = "2026-05-06T16:33:08.586Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1537,6 +1738,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.3.1" @@ -1648,6 +1861,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, ] +[[package]] +name = "rapidfuzz" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/21/ef6157213316e85790041254259907eb722e00b03480256c0545d98acd33/rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e", size = 57901753, upload-time = "2026-04-07T11:16:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/e3/574435c6aafb80254c191ef40d7aca2cb2bb97a095ec9395e9fa59ac307a/rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638", size = 1944601, upload-time = "2026-04-07T11:14:18.771Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1f/fbad3102a255ecc112ce9a7e779bacab7fd14398217be8868dc9082ba363/rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48", size = 1164293, upload-time = "2026-04-07T11:14:20.534Z" }, + { url = "https://files.pythonhosted.org/packages/88/37/a3eb7ff6121ed3a5f199a8c38cc86c8e481816f879cb0e0b738b078c9a7e/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1", size = 1371999, upload-time = "2026-04-07T11:14:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/79/72/97a9728c711c7c1b06e107d3f0623880fb4ef90e147ed13c551a1730e7cc/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6", size = 3145715, upload-time = "2026-04-07T11:14:24.508Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/d5caabbea233ac90c286c87c260e49d7641467e87438a18d858e41c82e91/rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741", size = 1456304, upload-time = "2026-04-07T11:14:26.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a7/2d1a81250ac8c01a0100c026018e76f0e7a097ff63e4c553e02a6938c6fb/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646", size = 2389089, upload-time = "2026-04-07T11:14:28.635Z" }, + { url = "https://files.pythonhosted.org/packages/65/0d/c47c3872203ae88e6506997c0b576ad731f5261daa25d559be09c9756658/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10", size = 2493404, upload-time = "2026-04-07T11:14:30.577Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/71e0a5a3130792146c8a200a2dd1e52aa16f7c1074012e17f2601eea9a90/rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9", size = 4251709, upload-time = "2026-04-07T11:14:32.451Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/d39874901abacef325adb5b34ae416817c8486dfb4fb87c7a9b74ec5b072/rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5", size = 1710069, upload-time = "2026-04-07T11:14:34.37Z" }, + { url = "https://files.pythonhosted.org/packages/85/0b/f65572c53de8a1c704bda707f63a447b67bdbe95d7cdc70d18885e191df5/rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9", size = 1540630, upload-time = "2026-04-07T11:14:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c3/143be3a578f989758cae516f3270d5cbb49783a7bfdf57cc27a670e00456/rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8", size = 813137, upload-time = "2026-04-07T11:14:38.289Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/252803f2010ba699618cdc048b6e1f7cc1f433c08b4a9a17579b92ab0142/rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6", size = 1940205, upload-time = "2026-04-07T11:14:40.319Z" }, + { url = "https://files.pythonhosted.org/packages/ea/59/b2afd98e41af9cd54554a4c1c423d84cdd60e6b1c0a09496f033b55f60ec/rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609", size = 1159639, upload-time = "2026-04-07T11:14:42.52Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/7aa7e62c4c516a7af322ed0c4f0774208b72d457d0cfec808bad0df12f4a/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f", size = 1367194, upload-time = "2026-04-07T11:14:44.25Z" }, + { url = "https://files.pythonhosted.org/packages/90/79/2fc252a63bc91d3c3b234d0a3a6ad4ebc460037a23cdcdaf9285f986e6c9/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7", size = 3151805, upload-time = "2026-04-07T11:14:46.21Z" }, + { url = "https://files.pythonhosted.org/packages/17/54/0c83508f2683ea70e2d05f8527eb07328acf7bb1e9d97a3bece5702378e7/rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e", size = 1455667, upload-time = "2026-04-07T11:14:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/71/1b/070175e873177814d58850a01ebe80e20ae11e93eb4da894d563988660fa/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610", size = 2388246, upload-time = "2026-04-07T11:14:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/77caf7aaf9c2be050ad1f128d7c24ff0f59079aa62c5f62f9df41c0af45e/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8", size = 2494333, upload-time = "2026-04-07T11:14:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/dd7e1f2aa31a8fbbfc16b0610af1d770ffaf1287490f3c8c5b1c52da264f/rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98", size = 4258579, upload-time = "2026-04-07T11:14:54.538Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/ac99e1ba347ba0e85e0bb60b74231d55fb93c0eff43f2920ccb413d0be08/rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc", size = 1709231, upload-time = "2026-04-07T11:14:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cb/0e251d731b3166378644238e8f0cf9e89858c024e19f75ca9f7e3ae83fd5/rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35", size = 1538519, upload-time = "2026-04-07T11:14:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/4548132acc947db6d5346a248e44a8b3a22d608ef30e770fb578caaf2d00/rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd", size = 812628, upload-time = "2026-04-07T11:15:00.552Z" }, + { url = "https://files.pythonhosted.org/packages/00/60/69b177577290c5eab892c6f75fe89c3aff3f9ae80298a78d9372b1cecb9a/rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8", size = 1970231, upload-time = "2026-04-07T11:15:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/48/38/2fd790052659cc4e2907b63c25433f0987864b445c1aeec1a302ef5ad948/rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9", size = 1194394, upload-time = "2026-04-07T11:15:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/80/f4/28430ad8472fc3536e8ebd51a864a226e979cfe924c6e3f83d111373aa74/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d", size = 1377051, upload-time = "2026-04-07T11:15:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/9aeacabcfd1e77397968362e5b98fe14248b8307011136b17daf99752a8e/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074", size = 3160565, upload-time = "2026-04-07T11:15:08.667Z" }, + { url = "https://files.pythonhosted.org/packages/56/f4/db4dd7be0cd2f2022117ac5407d905f435d60e48baaea313a567ad27e865/rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3", size = 1442113, upload-time = "2026-04-07T11:15:11.138Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/0e9f6aa57f3e32a767216f797e56dc96b720fcecfb9d8ee907ecc82f8d66/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09", size = 2396618, upload-time = "2026-04-07T11:15:13.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/44a78e39ffce17cbdd3e2b53b696acc751d5d153be0f499d052b07a4d904/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa", size = 2478220, upload-time = "2026-04-07T11:15:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/454311469a09a507e9d784a35796742bec22e4cebe75551e2da4e0e290fd/rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1", size = 4265027, upload-time = "2026-04-07T11:15:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/fc/01/175465a9ab3e3b70ba669058372f009d1d49c1746e2dcd56b69df188d3a5/rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f", size = 1766814, upload-time = "2026-04-07T11:15:19.687Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/a9b84a47af06ebed94a1439eb2f02adebfb8628bcd30af1fe3e02f5ef56c/rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a", size = 1582448, upload-time = "2026-04-07T11:15:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/5937800238b3f8248e70860d79f69ba8f73e764fff47e36bc9e2f26dbcc6/rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895", size = 832932, upload-time = "2026-04-07T11:15:24.358Z" }, + { url = "https://files.pythonhosted.org/packages/81/41/aa3ffb3355e62e1bf91f6599b3092e866bc88487a07c524004943c7676df/rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45", size = 1943327, upload-time = "2026-04-07T11:15:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e1/c2141f1840a41e07ad2db6f724945f8f8ff3065463899a22939152dd6e09/rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575", size = 1161755, upload-time = "2026-04-07T11:15:28.659Z" }, + { url = "https://files.pythonhosted.org/packages/ca/07/66e753eeaa353161d1d331b7dd517bb349b0bacfebe8496d7b26be26f81f/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280", size = 1376571, upload-time = "2026-04-07T11:15:31.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/85/9535df0b78ba51f478c9ce7eb6d1f85535cc31fe356773b48fd9d3e563ca/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e", size = 3156468, upload-time = "2026-04-07T11:15:33.428Z" }, + { url = "https://files.pythonhosted.org/packages/81/ee/b667eb93bba6dc4e0de658edd778e1619dc4d6aab68fa5e5c7f075152735/rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a", size = 1458311, upload-time = "2026-04-07T11:15:35.557Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ce/479074f5624364a48df3403c538797ef22d3ac49c19dc76c3f79fcdcc70c/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32", size = 2398228, upload-time = "2026-04-07T11:15:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/a8982f649150fffbdcd6f17565974501f6ab33b2795267bffbd4a7ba905b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246", size = 2497226, upload-time = "2026-04-07T11:15:39.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/5267c03ef6759831b7d4625a0c9c06e87baa2fae084b61ac9c388858317b/rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0", size = 4262283, upload-time = "2026-04-07T11:15:42.279Z" }, + { url = "https://files.pythonhosted.org/packages/71/c0/2579f343a97f5254c43bb5853baccc01488357dcb64a27bcb869b7888a4a/rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4", size = 1744614, upload-time = "2026-04-07T11:15:44.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/eb/8edfed1e80119dc9c35b11df4bc701eea85622ad681fff0263b6961d3224/rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502", size = 1588971, upload-time = "2026-04-07T11:15:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/f6/04/5676df93c85cfa57a3045d8047318df9f3cd58c7b8a99340dd95f874795e/rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13", size = 834985, upload-time = "2026-04-07T11:15:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4a8988cea658fe335048ddef8c876addff1b6daa3c9ca8ad65a5a2196e69/rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d", size = 1972517, upload-time = "2026-04-07T11:15:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/f5cfd9965a9d9a9e32249159797c47b5d6299ea6d1629f9126b25f1c10a3/rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99", size = 1196056, upload-time = "2026-04-07T11:15:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/64/07/561c2e40cfd10e6630a7b0ac5a2a813aef50d944bcd1f3d260319d659d5b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff", size = 1374732, upload-time = "2026-04-07T11:15:56.584Z" }, + { url = "https://files.pythonhosted.org/packages/c2/39/123bb94fee40e2fb3b7c49b80827c7ef42d838e18def3fc2fef5a3cf817a/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3", size = 3166902, upload-time = "2026-04-07T11:15:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/45716fafc9fd2e028cf20b5ac5bc704887081cd312f84edb0e325599414b/rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef", size = 1452130, upload-time = "2026-04-07T11:16:01.453Z" }, + { url = "https://files.pythonhosted.org/packages/ca/49/4e96c413114398481c0a5b0086af32c364a18613c9a2ea578d17c4bea4ee/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64", size = 2396308, upload-time = "2026-04-07T11:16:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/89/b7/49fea9fc6878d59bd259d01dd1972d9b86117992b1c66d9b16f0a65273c3/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261", size = 2488210, upload-time = "2026-04-07T11:16:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/0c/44/a1f732b93ffacbdad077b7c801149549b2938e1bece6addb5ad85ed74df8/rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df", size = 4270621, upload-time = "2026-04-07T11:16:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/ff942d19fce5385054650bb71a58495ddda299d94661ccc4e6e7fa44868b/rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279", size = 1803950, upload-time = "2026-04-07T11:16:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0f/9aafc63f9661222b819b391c187eed29fc90ad5935f9690e5ecc2d2047a4/rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66", size = 1632357, upload-time = "2026-04-07T11:16:13.1Z" }, + { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1713,13 +1989,113 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { 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]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +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 = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { 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]] name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -1803,6 +2179,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/18/dc99a152bea18a898a8ac387bfeb9ec0829e0f5bed11cfec2e2ca189c5a2/syrupy-5.2.0-py3-none-any.whl", hash = "sha256:798cb493a6e20f4839e58ea8f10eb1b0d85684c676442f79786e219bf32618e6", size = 51828, upload-time = "2026-05-16T21:11:34.984Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "time-machine" version = "3.2.0" @@ -1968,6 +2353,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"