diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b247e..b02f530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,14 @@ Initial alpha release. - Add `Span` object to represent a span annotation - Add `DocSpans` object to represent a set of span annotations for a document -- Add library `spans.match` of methods for checking for span-level matches +- Add library `span_utils` of methods for matching and scoring `Span` objects -### Alignment logic +### Alignment Logic - Add `SpanAlignment` object to represent an alignment between two sets of span annotations - Add library `align` of methods for aligning two sets of span annotations + +## Computing Evaluation Metrics + +- Add library `eval` of methods for computing the evaluation metrics for a `SpanAlignment` +- Add script `compute_metrics` for computing document- and entity-level aggregated evaluation metrics diff --git a/pyproject.toml b/pyproject.toml index e2df362..f5b66a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,10 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules" ] requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "orjsonl>=1.0.0", + "tqdm>=4.70.0", +] [dependency-groups] dev = [ diff --git a/src/spanerr/align.py b/src/spanerr/align.py index 4f7011a..01c759c 100644 --- a/src/spanerr/align.py +++ b/src/spanerr/align.py @@ -9,14 +9,17 @@ numeric score (float). It's used to measure the quality of a match. """ +from collections.abc import Callable + from spanerr.core import ( - CheckSpanPair, DocSpans, - ScoreSpanPair, Span, SpanAlignment, ) -from spanerr.spans.match import partial_overlap +from spanerr.span_utils import CheckSpanPair, ScoreSpanPair, partial_overlap + +# Custom function type +AlignSpans = Callable[[DocSpans, DocSpans], SpanAlignment] def select_first_match( @@ -122,3 +125,69 @@ def corppa_align( final_mapping[ref_span] = [sub_span] final_sys = DocSpans(sys.doc_id, final_sys_spans) return SpanAlignment(init_align.ref, final_sys, final_mapping) + + +def construct_aligner( + strategy: str, + is_match: CheckSpanPair | None = None, + score_match: ScoreSpanPair | None = None, + exclusive: bool | None = None, +) -> AlignSpans: + """ + Construct a span alignment method (AlignSpans) using a given alignment strategy + and accompanying parameters. + + Currently supports the following strategies: + - select_first: corresponds to select_first_match + - select_best: corresponds to select_best_match + - corppa: corresponds to corppa_align + """ + match strategy: + case "select_first": + # Validate input parameters + if is_match is None: + raise ValueError(f"Strategy {strategy} requires is_match parameter") + if score_match is not None: + raise ValueError( + f"Strategy {strategy} does not use score_match parameter" + ) + # Construct aligner + if exclusive is None: + return lambda r, s: select_first_match(r, s, is_match) + else: + return lambda r, s: select_first_match( + r, s, is_match, exclusive=exclusive + ) + case "select_best": + # Validate input parameters + if is_match is None or score_match is None: + raise ValueError( + f"Strategy {strategy} requires is_match and score_match parameters" + ) + # Construct aligner + if exclusive is None: + return lambda r, s: select_best_match(r, s, is_match, score_match) + else: + return lambda r, s: select_best_match( + r, s, is_match, score_match, exclusive=exclusive + ) + + case "corppa": + # Validate input parameters + if exclusive is not None: + raise ValueError( + f"Strategy {strategy} does not use exclusive parameter" + ) + # Construct aligner + if is_match is not None and score_match is not None: + return lambda r, s: corppa_align( + r, s, is_match=is_match, score_match=score_match + ) + elif is_match is not None: + return lambda r, s: corppa_align(r, s, is_match=is_match) + elif score_match is not None: + return lambda r, s: corppa_align(r, s, score_match=score_match) + else: + return lambda r, s: corppa_align(r, s) + case _: + raise ValueError(f"Unknown alignment strategy: {strategy}") diff --git a/src/spanerr/compute_metrics.py b/src/spanerr/compute_metrics.py new file mode 100644 index 0000000..ab3b8c2 --- /dev/null +++ b/src/spanerr/compute_metrics.py @@ -0,0 +1,361 @@ +""" +Script for computing aggregated span evaluation metrics for a given reference +and system span annotations as JSONL files. + +Currently this script supports the following functionality: + * Computing entity- or document-level aggregated metrics (macro level) + * Using the following span alignment strategies: + * select_first : Select first match where matching spans must have the same + label and overlapping boundaries. + * select_best : Select the best match where matching spans must have the + same label and overlapping boundaries and the best match corresponds to + the match with the largest jaccard similarity. + * corppa : The alignment strategy used by corppa. + * Using the following scoring strategies: + * jaccard: spans' jaccard similarity if their labels match, 0 otherwise. + * overlap_factor: spans' overlap factor if their labels match, 0 otherwise. + + +This script takes the following parameters: + 1. reference span annotations JSONL file + 2. system span annotations JSONL + 3. macro-level in which metrics will be aggregated + 4. span alignment strategy for computing metrics + 5. scoring strategy for scoring span alignments + +Example usage: + + compute_metrics.py ref.jsonl sys.jsonl entity select_first jaccard + + compute_metrics.py ref.jsonl sys.jsonl document corppa overlap_factor --no-progress +""" + +import argparse +from collections.abc import Callable, Iterator +from pathlib import Path + +import orjsonl +from tqdm import tqdm + +from spanerr.align import AlignSpans, construct_aligner +from spanerr.core import DocSpans, Span, SpanAlignment +from spanerr.eval import ( + f_beta, + precision, + recall, + relevance_score, +) +from spanerr.span_utils import composite_match_score, partial_overlap + +# Custom function type +ScoreAlignment = Callable[[SpanAlignment], float] + + +def get_aligner( + strategy: str, +) -> AlignSpans: + """ + Returns alignment method for the specified alignment strategy. + Currently, the supported strategies use the following defaults: + - select_first: + * span match: same label and overlapping boundaries (spanerr.span_utils.partial_overlap) + - select_best: + * span match: same label and overlapping boundaries (spanerr.span_utils.partial_overlap) + * match score: jaccard similarity + - corppa: uses strategy's defaults + """ + match strategy: + case "select_first": + return construct_aligner("select_first", is_match=partial_overlap) + case "select_best": + return construct_aligner( + "select_best", is_match=partial_overlap, score_match=Span.jaccard + ) + case "corppa": + # Uses corppa_align's defaults + return construct_aligner("corppa") + case _: + raise ValueError(f"Unknown alignment strategy: {strategy}") + + +def get_scorer( + strategy: str, + partial_weight: float = 1, +) -> ScoreAlignment: + """ + Returns a method for calculating an alignment's relevance score using + the provided scoring strategy and optionally a partial weight. + + Currently supports the following scoring strategies: + - overlap_factor: spans' overlap factor if their labels match, 0 otherwise + - jaccard: spans' jaccard similarity if their labels match, 0 otherwise + """ + match strategy: + case "overlap_factor": + score_bounds = Span.overlap_factor + case "jaccard": + score_bounds = Span.jaccard + case _: + raise ValueError(f"Unknown scoring strategy: {strategy}") + match_score = lambda a, b: composite_match_score(a, b, score_bounds) + return lambda x: relevance_score(x, match_score, partial_weight=partial_weight) + + +def get_span_alignments( + ref_file: Path, + sys_file: Path, + aligner: AlignSpans, + ignore_unmatched: bool = False, +) -> Iterator[SpanAlignment]: + """ + Yields document-level span alignments given two sets of span annotations (JSONL). + By default, any documents represented by only a reference or system annotation + are included by assuming its missing pair contains no span annotations. + """ + # Validate input files + if not ref_file.is_file(): + raise ValueError("Reference annotations file does not exist") + if not sys_file.is_file(): + raise ValueError("System annotations file does not exist") + + # Read in system annotations + sys_annos = {} + for sys_dict in orjsonl.stream(sys_file): + anno = DocSpans.from_dict(sys_dict) # ty: ignore[invalid-argument-type] + doc_id = anno.doc_id + # Validate system annotations by checking for duplicate doc ids + if doc_id in sys_annos: + # Raise an error if a doc id is encountered more than once + raise ValueError(f"Multiple system annotations with document id '{doc_id}'") + sys_annos[doc_id] = anno + # Then for each reference annotations, build corresponding alignment + ref_doc_ids = set() # for tracking encountered reference doc ids + for ref_json in orjsonl.stream(ref_file): + ref_anno = DocSpans.from_dict(ref_json) # ty: ignore[invalid-argument-type] + doc_id = ref_anno.doc_id + # Validate reference annotations by checking for duplicate doc ids + if doc_id in ref_doc_ids: + # Raise an error if a doc id is encountered more than once + raise ValueError( + f"Multiple reference annotations with document id '{doc_id}'" + ) + if doc_id not in sys_annos: + # Optionally add unmatched reference annotation + if ignore_unmatched: + continue + yield SpanAlignment(ref_anno, DocSpans(doc_id, []), {}) + else: + # Remove matched system annotations + yield aligner(ref_anno, sys_annos.pop(doc_id)) + ref_doc_ids.add(doc_id) + # Then optionally add unmatched system annotations + if not ignore_unmatched: + for doc_id, sys_anno in sys_annos.items(): + yield SpanAlignment(DocSpans(doc_id, []), sys_anno, {}) + + +def compute_entity_metrics( + alignments: Iterator[SpanAlignment], + scorer: ScoreAlignment, + beta: float = 1, + show_progress: bool = True, +) -> dict[str, float]: + """ + Computes entity-level precision, recall, and F-score for series of SpanAlignments. + By default, computes F1 scores. + + Returns results as a dict with the following fields: + - n_docs = number of documents + - precision = entity-level precision + - recall = entity-level recall + - f-score = entity-level F-score + """ + if show_progress: + print("Computing entity-level metrics...") + # Compute aggregated statistics needed to calculate precision and recall + n_docs = 0 + total_relevance = 0 + total_sys_spans = 0 + total_ref_spans = 0 + progress = tqdm(alignments, desc="Scoring alignments", disable=not show_progress) + for a in progress: + n_docs += 1 + rel_score = scorer(a) + n_sys_spans = len(a.sys.spans) + n_ref_spans = len(a.ref.spans) + if show_progress: + tqdm.write( + f" * {a.ref.doc_id}: relevance = {rel_score:.4g} | " + f"{n_ref_spans} ref spans | {n_sys_spans} sys spans" + ) + total_relevance += rel_score + total_sys_spans += n_sys_spans + total_ref_spans += n_ref_spans + # Raise error if iterator contains no alignments + if n_docs == 0: + raise ValueError("Found no alignments to score") + # Compute entity-level precision and recall + avg_precision = precision(total_sys_spans, total_relevance) + avg_recall = recall(total_ref_spans, total_relevance) + avg_fscore = f_beta(beta, avg_precision, avg_recall) + return { + "n_docs": n_docs, + "precision": avg_precision, + "recall": avg_recall, + "f-score": avg_fscore, + } + + +def compute_document_metrics( + alignments: Iterator[SpanAlignment], + scorer: ScoreAlignment, + beta: float = 1, + show_progress: bool = True, +) -> dict[str, float]: + """ + Computes document-level precision, recall, and F-score. By default, + computes F1 scores. + + Returns results as a dict with the following fields: + - n_docs = number of documents + - precision = document-level precision + - recall = document-level recall + - f-score = document-level F-score + """ + if show_progress: + print("Computing document-level metrics...") + # Compute aggregated statistics needed to calculate precision and recall + n_docs = 0 + cumulative_precision = 0 + cumulative_recall = 0 + cumulative_fscore = 0 + progress = tqdm(alignments, desc="Scoring alignments", disable=not show_progress) + for a in progress: + n_docs += 1 + relevance = scorer(a) + # Compute and accumulate precision and recall + doc_precision = precision(len(a.sys.spans), relevance) + doc_recall = recall(len(a.ref.spans), relevance) + doc_fscore = f_beta(beta, doc_precision, doc_recall) + if show_progress: + tqdm.write( + f" * {a.ref.doc_id}: precision = {doc_precision:.4g} | " + f"recall = {doc_recall:.4g} | F-{beta} = {doc_fscore:.4g}" + ) + # Add to running totals + cumulative_precision += doc_precision + cumulative_recall += doc_recall + cumulative_fscore += doc_fscore + # Raise error if iterator contains no alignments + if n_docs == 0: + raise ValueError("Found no alignments to score") + # Compute entity-level precision and recall + avg_precision = cumulative_precision / n_docs + avg_recall = cumulative_recall / n_docs + avg_fscore = cumulative_fscore / n_docs + return { + "n_docs": n_docs, + "precision": avg_precision, + "recall": avg_recall, + "f-score": avg_fscore, + } + + +def compute_macro_metrics( + ref_jsonl: Path, + sys_jsonl: Path, + macro_level: str, + aligner: AlignSpans, + scorer: ScoreAlignment, + beta: float = 1, + show_progress: bool = True, +) -> dict[str, float]: + """ + Compute aggregated precision, recall, and F-score at the specified macro level. + By default, computes F1-scores. + + Returns the following tuple: + - number of documents: int + - macro precision: float + - macro recall: float + - macro F-score: float + """ + # Step 1: Span Alignments + alignments = get_span_alignments(ref_jsonl, sys_jsonl, aligner) + # Step 2: Compute macro metrics + match macro_level: + case "entity": + return compute_entity_metrics( + alignments, scorer, beta=beta, show_progress=show_progress + ) + case "document": + return compute_document_metrics( + alignments, scorer, beta=beta, show_progress=show_progress + ) + case _: + raise ValueError(f"Unsupported macro level: {macro_level}") + + +def main(): + """ + Command-line access to computing macro-level evaluation metrics for a given pair of + reference and system span annotations. + """ + parser = argparse.ArgumentParser( + description="Calculate aggregated span evaluation metrics" + ) + # Required arguments + parser.add_argument( + "ref_jsonl", + help="Path to reference span annotations (JSONL file)", + type=Path, + ) + parser.add_argument( + "sys_jsonl", + help="Path to system span annotations (JSONL file)", + type=Path, + ) + parser.add_argument( + "macro_level", + choices=["entity", "document"], + help="Level in which the evaluation metrics are aggregated", + ) + parser.add_argument( + "alignment_method", + choices=["select_first", "select_best", "corppa"], + help="Strategy for aligning span annotations", + ) + parser.add_argument( + "scoring_method", + choices=["jaccard", "overlap_factor"], + help="Strategy for scoring partial matches", + ) + # Optional arguments + parser.add_argument( + "--progress", + help="Show progress", + action=argparse.BooleanOptionalAction, + default=True, + ) + args = parser.parse_args() + # Compute aggregated evaluation metrics + results = compute_macro_metrics( + args.ref_jsonl, + args.sys_jsonl, + args.macro_level, + get_aligner(args.alignment_method), + get_scorer(args.scoring_method), + show_progress=args.progress, + ) + if args.progress: + print() + print( + f"Macro {args.macro_level}-level metrics for {results['n_docs']:d} documents:" + ) + print(f"- Precision = {results['precision']:.4g}") + print(f"- Recall = {results['recall']:.4g}") + print(f"- F1 = {results['f-score']:.4g}") + + +if __name__ == "__main__": + main() diff --git a/src/spanerr/core.py b/src/spanerr/core.py index 4541cc3..840d9f7 100644 --- a/src/spanerr/core.py +++ b/src/spanerr/core.py @@ -3,7 +3,7 @@ """ from collections import defaultdict -from collections.abc import Callable, Iterable +from collections.abc import Iterable from copy import deepcopy from dataclasses import dataclass from functools import cached_property @@ -121,12 +121,12 @@ class DocSpans: """ doc_id: str - _spans: list[Span] # meant to be immutable + _spans: tuple[Span] # use tuple for immutability def __init__(self, doc_id: str, spans: Iterable[Span]): object.__setattr__(self, "doc_id", doc_id) # Ensure spans are a sorted copy of input - object.__setattr__(self, "_spans", sorted(spans)) + object.__setattr__(self, "_spans", tuple(sorted(spans))) @classmethod def from_dict(cls, doc_dict: dict) -> Self: @@ -149,7 +149,7 @@ def spans(self) -> list[Span]: """ Returns copy of span annotations """ - return self._spans.copy() + return list(self._spans) def aggregate(self, concat: bool = False) -> Self: """ @@ -191,6 +191,8 @@ def binarize(self, concat: bool = False) -> Self: class SpanAlignment: """ Alignment object for two sets of span annotations over a shared document. + + Note: Designed to be immutable, but is not hashable because of mapping field (dict). """ ref: DocSpans @@ -234,8 +236,3 @@ def reverse_mapping(self) -> MappingProxyType[Span, list[Span]]: for sys_span in sys_spans: rev_map[sys_span].append(ref_span) return MappingProxyType(rev_map) - - -# Additional function types -CheckSpanPair = Callable[[Span, Span], bool] -ScoreSpanPair = Callable[[Span, Span], float] diff --git a/src/spanerr/eval.py b/src/spanerr/eval.py new file mode 100644 index 0000000..6813b0a --- /dev/null +++ b/src/spanerr/eval.py @@ -0,0 +1,77 @@ +""" +Library of methods for evaluating alignments of span annotations +""" + +from spanerr.core import Span, SpanAlignment +from spanerr.span_utils import CheckSpanPair, ScoreSpanPair + + +def is_one_to_one(alignment: SpanAlignment) -> bool: + """ + Tests if the alignment has a one-to-one mapping where each reference span (key) + maps to (at most) a single distinct system span. + """ + return all(len(sys_spans) == 1 for sys_spans in alignment.mapping.values()) and all( + len(ref_spans) == 1 for ref_spans in alignment.reverse_mapping.values() + ) + + +def relevance_score( + alignment: SpanAlignment, + partial_score: ScoreSpanPair, + partial_weight: float = 1, + is_exact_match: CheckSpanPair = Span.__eq__, +) -> float: + """ + Calculate the alignment's relevance score. This corresponds to the + effective number of relevant spans retrieved (TP). + + The relevance score is computed as follows: + # exact matches + partial_weight * sum of partial match scores + + The match scores for partial matches are determined by `partial_score`. + By default, exact matches correspond to span equality (i.e., exact boundary + and label match) but this can be customized via `is_exact_match`. + """ + # Verify the weight is between 0 and 1 + if partial_weight < 0 or partial_weight > 1: + raise ValueError("Partial weight must be between 0 and 1 (inclusive)") + + if is_one_to_one(alignment): + score = 0 + for ref_span in alignment.mapping: + sys_span = alignment.mapping[ref_span][0] + if is_exact_match(ref_span, sys_span): + score += 1 + else: + score += partial_weight * partial_score(ref_span, sys_span) + return score + else: + raise ValueError( + "Unsupported alignment. Currently, cannot score alignments " + "without one-to-one mappings." + ) + + +def precision(n_sys_spans: int, relevance_score: float): + """ + Calculate precision. Returns 1 if there are no system spans. + """ + return 1 if not n_sys_spans else relevance_score / n_sys_spans + + +def recall(n_ref_spans: int, relevance_score: float): + """ + Calculate recall. Returns 1 if there are no reference spans. + """ + return 1 if not n_ref_spans else relevance_score / n_ref_spans + + +def f_beta(beta: float, precision: float, recall: float): + """ + Calculate F beta score. Beta indicates how many more times important recall + is over precision. Returns 0 if precision and recall are 0. + """ + if precision == 0 or recall == 0: + return 0 # Since FP > 0 or FN > 0 + return (1 + beta**2) * precision * recall / (beta**2 * precision + recall) diff --git a/src/spanerr/span_utils.py b/src/spanerr/span_utils.py new file mode 100644 index 0000000..c5e1e2a --- /dev/null +++ b/src/spanerr/span_utils.py @@ -0,0 +1,57 @@ +""" +Library of methods for comparing spans +""" + +from collections.abc import Callable + +from spanerr.core import Span + +# Custom function types +CheckSpanPair = Callable[[Span, Span], bool] +ScoreSpanPair = Callable[[Span, Span], float] +ScoreLabelPair = Callable[[str, str], float] + + +def exact_match(span_a: Span, span_b: Span) -> bool: + """ + Test for an exact match where there there is an exact boundary match and + label match. This corresponds to a strict match in SemEval'13. + """ + return span_a == span_b + + +def partial_overlap(span_a: Span, span_b: Span) -> bool: + """ + Test for an overlapping boundary match and label match. This corresponds to + to a type match in SemEval'13. + """ + return span_a.label == span_b.label and span_a.overlap_length(span_b) > 0 + + +def min_overlap_length(span_a: Span, span_b: Span, min_len: int) -> bool: + """ + Test for a label match and an overlap of at least `min_len` length. + """ + return span_a.label == span_b.label and span_a.overlap_length(span_b) >= min_len + + +def min_overlap_factor(span_a: Span, span_b: Span, min_val: float) -> bool: + """ + Tests for a label match and an overlap factor of at least `min_val`. + """ + return span_a.label == span_b.label and span_a.overlap_factor(span_b) >= min_val + + +def composite_match_score( + span_a: Span, + span_b: Span, + score_bounds: ScoreSpanPair, + score_label: ScoreLabelPair = str.__eq__, +) -> float: + """ + Computes a composite match score that is the product of the two spans' boundary + and label similarities. Boundary similarity is calculated using `score_bounds`. + By default, label similarity is simple string equality but can be customized + using `score_label`. + """ + return score_label(span_a.label, span_b.label) * score_bounds(span_a, span_b) diff --git a/src/spanerr/spans/match.py b/src/spanerr/spans/match.py deleted file mode 100644 index 657d049..0000000 --- a/src/spanerr/spans/match.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Library of methods for checking for span-level matches -""" - -from spanerr.core import Span - - -def exact_match(span_a: Span, span_b: Span, ignore_label: bool = False) -> bool: - """ - Test for exact boundary match. By default, must have same label. - Corresponds to strict and exact match in SemEval'13. - """ - if ignore_label: - return span_a.binarize() == span_b.binarize() - else: - return span_a == span_b - - -def partial_overlap(span_a: Span, span_b: Span, ignore_label: bool = False) -> bool: - """ - Test for overlapping boundary match. By default, must have same label. - Corresponds to type and partial match in SemEval'13. - """ - if ignore_label or span_a.label == span_b.label: - return span_a.overlap_length(span_b) > 0 - else: - return False - - -def min_overlap_length( - span_a: Span, span_b: Span, min_len: int, ignore_label: bool = False -) -> bool: - """ - Test if spans overlap by at least `min_len` length. - By default, must have same label. - """ - if ignore_label or span_a.label == span_b.label: - return span_a.overlap_length(span_b) >= min_len - else: - return False - - -def min_overlap_factor( - span_a: Span, span_b: Span, min_val: float, ignore_label: bool = False -) -> bool: - """ - Test if spans have an overlap factor of at least `min_val`. - By default, must have same label. - """ - if ignore_label or span_a.label == span_b.label: - return span_a.overlap_factor(span_b) >= min_val - else: - return False diff --git a/tests/test_align.py b/tests/test_align.py index aae54e9..fd690d4 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -1,18 +1,23 @@ -from unittest.mock import Mock +from unittest.mock import Mock, patch + +import pytest from spanerr.align import ( + construct_aligner, corppa_align, select_best_match, select_first_match, ) from spanerr.core import ( - CheckSpanPair, DocSpans, - ScoreSpanPair, Span, SpanAlignment, ) -from spanerr.spans.match import partial_overlap +from spanerr.span_utils import ( + CheckSpanPair, + ScoreSpanPair, + partial_overlap, +) def test_select_first_match(): @@ -179,3 +184,91 @@ def test_corppa_align(): } result = corppa_align(ref, sys, is_match=Span.has_overlap) assert result == SpanAlignment(ref, expected_sys, expected_map) + + +@patch("spanerr.align.corppa_align", autospec=True) +@patch("spanerr.align.select_best_match", autospec=True) +@patch("spanerr.align.select_first_match", autospec=True) +def test_construct_aligner(mock_first, mock_best, mock_corppa): + # Unknown strategy + strategy = "other" + err_msg = "Unknown alignment strategy: other" + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy) + # select_first + strategy = "select_first" + ## Missing is_match parameter + err_msg = "Strategy select_first requires is_match parameter" + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy) + ## Includes extra score_match parameter + err_msg = "Strategy select_first does not use score_match parameter" + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy, is_match="test", score_match="score") + ## Default + aligner = construct_aligner(strategy, is_match="test") + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_first.assert_called_once_with("ref_span", "sys_span", "test") + ## Set optional exclusive flag + mock_first.reset_mock() + aligner = construct_aligner(strategy, is_match="test", exclusive="flag") + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_first.assert_called_once_with("ref_span", "sys_span", "test", exclusive="flag") + # select_best + strategy = "select_best" + ## Missing required input parameters + err_msg = "Strategy select_best requires is_match and score_match parameters" + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy) + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy, is_match="test") + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy, score_match="score") + ## Default + aligner = construct_aligner(strategy, is_match="test", score_match="score") + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_best.assert_called_once_with("ref_span", "sys_span", "test", "score") + ## Set optional exclusive flag + mock_best.reset_mock() + aligner = construct_aligner( + strategy, is_match="test", score_match="score", exclusive="flag" + ) + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_best.assert_called_once_with( + "ref_span", "sys_span", "test", "score", exclusive="flag" + ) + # corppa + strategy = "corppa" + ## Includes extra exclusive parameter + err_msg = "Strategy corppa does not use exclusive parameter" + with pytest.raises(ValueError, match=err_msg): + construct_aligner(strategy, exclusive="flag") + ## Default + aligner = construct_aligner(strategy) + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_corppa.assert_called_once_with("ref_span", "sys_span") + ## Set optional is_match parameter + mock_corppa.reset_mock() + aligner = construct_aligner(strategy, is_match="test") + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_corppa.assert_called_once_with("ref_span", "sys_span", is_match="test") + ## Set optional score_match parameter + mock_corppa.reset_mock() + aligner = construct_aligner(strategy, score_match="score") + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_corppa.assert_called_once_with("ref_span", "sys_span", score_match="score") + ## Set both optional parameters + mock_corppa.reset_mock() + aligner = construct_aligner(strategy, is_match="test", score_match="score") + assert callable(aligner) + _ = aligner("ref_span", "sys_span") + mock_corppa.assert_called_once_with( + "ref_span", "sys_span", is_match="test", score_match="score" + ) diff --git a/tests/test_compute_metrics.py b/tests/test_compute_metrics.py new file mode 100644 index 0000000..8a996c9 --- /dev/null +++ b/tests/test_compute_metrics.py @@ -0,0 +1,539 @@ +import json +from pathlib import Path +from unittest.mock import Mock, call, patch + +import pytest + +from spanerr.align import AlignSpans +from spanerr.compute_metrics import ( + ScoreAlignment, + compute_document_metrics, + compute_entity_metrics, + compute_macro_metrics, + get_aligner, + get_scorer, + get_span_alignments, + main, +) +from spanerr.core import DocSpans, Span, SpanAlignment +from spanerr.span_utils import partial_overlap + + +@patch("spanerr.compute_metrics.construct_aligner", autospec=True) +def test_get_aligner(mock_constructor): + mock_constructor.return_value = "result_aligner" + # Unknown strategy + err_msg = "Unknown alignment strategy: other" + with pytest.raises(ValueError, match=err_msg): + get_aligner("other") + mock_constructor.assert_not_called() + # select_first + assert get_aligner("select_first") == "result_aligner" + mock_constructor.assert_called_once_with("select_first", is_match=partial_overlap) + # select_best + mock_constructor.reset_mock() + assert get_aligner("select_best") == "result_aligner" + mock_constructor.assert_called_once_with( + "select_best", is_match=partial_overlap, score_match=Span.jaccard + ) + # corppa + mock_constructor.reset_mock() + assert get_aligner("corppa") == "result_aligner" + mock_constructor.assert_called_once_with("corppa") + + +@patch("spanerr.compute_metrics.composite_match_score", autospec=True) +@patch("spanerr.compute_metrics.relevance_score", autospec=True) +def test_get_scorer(mock_relevance, mock_composite): + mock_relevance.return_value = "relevance score" + mock_composite.return_value = "match score" + # Unknown strategy + err_msg = "Unknown scoring strategy: other" + with pytest.raises(ValueError, match=err_msg): + get_scorer("other") + mock_relevance.assert_not_called() + mock_composite.assert_not_called() + # overlap factor (with defaults) + scorer = get_scorer("overlap_factor") + assert callable(scorer) + _ = scorer("span alignment") + mock_relevance.assert_called_once() + ## Check basic call args + assert mock_relevance.call_args.args[0] == "span alignment" + assert mock_relevance.call_args.kwargs == {"partial_weight": 1} + ### Check function call arg + match_score = mock_relevance.call_args.args[1] + assert callable(match_score) + _ = match_score("span_a", "span_b") + mock_composite.assert_called_once_with("span_a", "span_b", Span.overlap_factor) + # jaccard + mock_relevance.reset_mock() + mock_composite.reset_mock() + scorer = get_scorer("jaccard") + _ = scorer("span alignment") + mock_relevance.assert_called_once() + assert callable(scorer) + ## Check basic call args + assert mock_relevance.call_args.args[0] == "span alignment" + assert mock_relevance.call_args.kwargs == {"partial_weight": 1} + ### Check function call arg + match_score = mock_relevance.call_args.args[1] + assert callable(match_score) + _ = match_score("span_a", "span_b") + mock_composite.assert_called_once_with("span_a", "span_b", Span.jaccard) + # set partial weight + for strategy in ["overlap_factor", "jaccard"]: + mock_relevance.reset_mock() + scorer = get_scorer(strategy, partial_weight=0.5) + _ = scorer("span alignment") + assert mock_relevance.call_args.kwargs == {"partial_weight": 0.5} + + +# Mocking SpanAlignment since the objects are not hashable +@patch("spanerr.compute_metrics.SpanAlignment", autospec=True) +def test_get_span_alignments(mock_alignment, tmp_path): + # Note: get_span_alignments is a generator so need to make sure to consume result + # JSONL test files + empty_jsonl = tmp_path / "empty.jsonl" + empty_jsonl.touch() + # Invalid ref and sys JSONL files + with pytest.raises(ValueError, match="Reference annotations file does not exist"): + list(get_span_alignments(tmp_path / "missing", empty_jsonl, "aligner")) + with pytest.raises(ValueError, match="System annotations file does not exist"): + list(get_span_alignments(empty_jsonl, tmp_path, "aligner")) + + # Ref and sys are 1-1 (don't make assumptions about yield order) + mock_aligner = Mock(spec=AlignSpans, side_effect=lambda a, _: f"{a.doc_id}") + ref_jsonl = tmp_path / "ref.jsonl" + sys_jsonl = tmp_path / "sys.jsonl" + ## same order + ref_lines = [ + '{"doc_id":"a","spans":[{"start":1,"end":2}]}', + '{"doc_id":"b","spans":[{"start":3,"end":4}]}', + ] + sys_lines = [ + '{"doc_id":"a","spans":[{"start":4,"end":5}]}', + '{"doc_id":"b","spans":[{"start":6,"end":7}]}', + ] + ref_jsonl.write_text("\n".join(ref_lines) + "\n") + sys_jsonl.write_text("\n".join(sys_lines) + "\n") + ref_docspans = [DocSpans.from_dict(json.loads(l)) for l in ref_lines] + sys_docspans = [DocSpans.from_dict(json.loads(l)) for l in sys_lines] + result = get_span_alignments(ref_jsonl, sys_jsonl, mock_aligner) + assert set(result) == {"a", "b"} + assert mock_aligner.call_count == 2 + expected_calls = [ + call(ref_docspans[0], sys_docspans[0]), + call(ref_docspans[1], sys_docspans[1]), + ] + mock_aligner.assert_has_calls(expected_calls, any_order=True) + mock_alignment.assert_not_called() + ## different order + mock_aligner.reset_mock() + sys_jsonl.write_text("\n".join(sys_lines[::-1]) + "\n") + result = get_span_alignments(ref_jsonl, sys_jsonl, mock_aligner) + assert set(result) == {"a", "b"} + assert mock_aligner.call_count == 2 + mock_aligner.assert_has_calls(expected_calls, any_order=True) + mock_alignment.assert_not_called() + + # Ref and sys are not 1-1 + ## No system spans + mock_aligner.reset_mock() + mock_alignment.side_effect = lambda a, b, c: a.doc_id + result = get_span_alignments(ref_jsonl, empty_jsonl, mock_aligner) + assert set(result) == {"a", "b"} + mock_aligner.assert_not_called() + assert mock_alignment.call_count == 2 + expected_calls = [ + call(ref_docspans[0], DocSpans("a", []), {}), + call(ref_docspans[1], DocSpans("b", []), {}), + ] + mock_alignment.assert_has_calls(expected_calls, any_order=True) + ### ignore unmatched + mock_aligner.reset_mock() + mock_alignment.reset_mock() + result = get_span_alignments( + ref_jsonl, empty_jsonl, mock_aligner, ignore_unmatched=True + ) + assert list(result) == [] + mock_aligner.assert_not_called() + mock_alignment.assert_not_called() + + ## No reference spans + mock_aligner.reset_mock() + result = get_span_alignments(empty_jsonl, sys_jsonl, mock_aligner) + mock_alignment.reset_mock() + assert set(result) == {"a", "b"} + mock_aligner.assert_not_called() + assert mock_alignment.call_count == 2 + expected_calls = [ + call(DocSpans("a", []), sys_docspans[0], {}), + call(DocSpans("b", []), sys_docspans[1], {}), + ] + mock_alignment.assert_has_calls(expected_calls, any_order=True) + ### ignore unmatched + mock_aligner.reset_mock() + mock_alignment.reset_mock() + result = get_span_alignments( + empty_jsonl, sys_jsonl, mock_aligner, ignore_unmatched=True + ) + assert list(result) == [] + mock_aligner.assert_not_called() + mock_alignment.assert_not_called() + + ## Mismatched + ref_first_jsonl = tmp_path / "ref_first.jsonl" + ref_first_jsonl.write_text(ref_lines[0] + "\n") + sys_last_jsonl = tmp_path / "sys_last.jsonl" + sys_last_jsonl.write_text(sys_lines[-1] + "\n") + + ### ref: 1 docs, sys: 2 docs + mock_aligner.reset_mock() + mock_alignment.reset_mock() + result = get_span_alignments(ref_first_jsonl, sys_jsonl, mock_aligner) + assert set(result) == {"a", "b"} + mock_aligner.assert_called_once_with(ref_docspans[0], sys_docspans[0]) + mock_alignment.assert_called_once_with(DocSpans("b", []), sys_docspans[1], {}) + #### ignore unmatched + mock_aligner.reset_mock() + mock_alignment.reset_mock() + result = get_span_alignments( + ref_first_jsonl, sys_jsonl, mock_aligner, ignore_unmatched=True + ) + assert set(result) == {"a"} + mock_aligner.assert_called_once_with(ref_docspans[0], sys_docspans[0]) + mock_alignment.assert_not_called() + + ### ref: 2 docs, sys: 1 docs + mock_aligner.reset_mock() + mock_alignment.reset_mock() + result = get_span_alignments(ref_jsonl, sys_last_jsonl, mock_aligner) + assert set(result) == {"a", "b"} + mock_aligner.assert_called_once_with(ref_docspans[1], sys_docspans[1]) + mock_alignment.assert_called_once_with(ref_docspans[0], DocSpans("a", []), {}) + #### ignore unmatched + mock_aligner.reset_mock() + mock_alignment.reset_mock() + result = get_span_alignments( + ref_jsonl, sys_last_jsonl, mock_aligner, ignore_unmatched=True + ) + assert set(result) == {"b"} + mock_aligner.assert_called_once_with(ref_docspans[1], sys_docspans[1]) + mock_alignment.assert_not_called() + + +@pytest.mark.parametrize( + "jsonl_text,dup_id", + [ + ['{"doc_id":"a","spans":[]}\n{"doc_id":"a","spans":[]}\n', "a"], + ['{"spans":[]}\n{"spans":[]}\n', ""], + ['{"doc_id":"","spans":[]}\n{"spans":[]}\n', ""], + [ + '{"doc_id":"a","spans":[]}\n{"doc_id":"b","spans":[]}\n{"doc_id":"a","spans":[]}\n', + "a", + ], + ['{"spans":[]}\n{"doc_id":"a","spans":[]}\n{"spans":[]}\n', ""], + ], +) +def test_get_span_alignments_dup_doc_ids(tmp_path, jsonl_text, dup_id): + # Create input JSONL files + empty_jsonl = tmp_path / "empty.jsonl" + empty_jsonl.touch() + dup_jsonl = tmp_path / "dup.jsonl" + dup_jsonl.write_text(jsonl_text) + ## System annotations with duplicate doc_ids + err_msg = f"Multiple system annotations with document id '{dup_id}'" + with pytest.raises(ValueError, match=err_msg): + list(get_span_alignments(empty_jsonl, dup_jsonl, "aligner")) + ## Reference annotations with duplicate doc_ids + err_msg = f"Multiple reference annotations with document id '{dup_id}'" + with pytest.raises(ValueError, match=err_msg): + list(get_span_alignments(dup_jsonl, empty_jsonl, "aligner")) + + +@patch("spanerr.compute_metrics.f_beta", autospec=True, return_value="f") +@patch("spanerr.compute_metrics.recall", autospec=True, return_value="r") +@patch("spanerr.compute_metrics.precision", autospec=True, return_value="p") +def test_compute_entity_metrics(mock_precision, mock_recall, mock_fscore): + mock_scorer = Mock(autospec=ScoreAlignment, return_value=2) + # Error: No alignments + with pytest.raises(ValueError, match="Found no alignments to score"): + compute_entity_metrics([], mock_scorer, show_progress=False) + mock_scorer.assert_not_called() + mock_precision.assert_not_called() + mock_recall.assert_not_called() + mock_fscore.assert_not_called() + + # Typical case with defaults (so F-1 score) + ## Overall: n_ref_spans = 2, n_sys_spans = 1 + alignments = [ + SpanAlignment(DocSpans("a", []), DocSpans("a", []), {}), + SpanAlignment( + DocSpans("b", [Span(1, 2), Span(3, 4)]), DocSpans("b", [Span(4, 5)]), {} + ), + ] + expected = {"n_docs": 2, "precision": "p", "recall": "r", "f-score": "f"} + result = compute_entity_metrics(alignments, mock_scorer, show_progress=False) + assert result == expected + assert mock_scorer.call_count == 2 + mock_scorer.assert_has_calls([call(a) for a in alignments]) + mock_precision.assert_called_once_with(1, 2 * 2) + mock_recall.assert_called_once_with(2, 2 * 2) + mock_fscore.assert_called_once_with(1, "p", "r") + + # Typical case but using F-0.5 score (beta=0.5) + mock_scorer.reset_mock() + mock_precision.reset_mock() + mock_recall.reset_mock() + mock_fscore.reset_mock() + ## Overall: n_ref_spans = 4, n_sys_spans = 6 + alignments.append( + SpanAlignment( + DocSpans("c", [Span(0, 1), Span(1, 2)]), + DocSpans("c", [Span(0, 1), Span(1, 2), Span(2, 3), Span(3, 4), Span(4, 5)]), + {}, + ) + ) + expected["n_docs"] = 3 + result = compute_entity_metrics( + alignments, mock_scorer, beta=0.5, show_progress=False + ) + assert result == expected + assert mock_scorer.call_count == 3 + mock_scorer.assert_has_calls([call(a) for a in alignments]) + mock_precision.assert_called_once_with(6, 2 * 3) + mock_recall.assert_called_once_with(4, 2 * 3) + mock_fscore.assert_called_once_with(0.5, "p", "r") + + +@patch("spanerr.compute_metrics.f_beta", autospec=True) +@patch("spanerr.compute_metrics.recall", autospec=True) +@patch("spanerr.compute_metrics.precision", autospec=True) +def test_document_entity_metrics(mock_precision, mock_recall, mock_fscore): + mock_scorer = Mock(autospec=ScoreAlignment, return_value="score") + # Error: no alignments + with pytest.raises(ValueError, match="Found no alignments to score"): + compute_document_metrics([], mock_scorer, show_progress=False) + mock_scorer.assert_not_called() + mock_precision.assert_not_called() + mock_recall.assert_not_called() + mock_fscore.assert_not_called() + + # Typical case with defaults (so F-1 score) + mock_precision.side_effect = [0.0, 0.1] + mock_recall.side_effect = [0.4, 0.5] + mock_fscore.side_effect = [0, 1] + alignments = [ + ## n_ref_spans = 0, n_sys_spans = 0 + SpanAlignment(DocSpans("a", []), DocSpans("a", []), {}), + ## n_ref_spans = 2, n_sys_spans = 1 + SpanAlignment( + DocSpans("b", [Span(1, 2), Span(3, 4)]), DocSpans("b", [Span(4, 5)]), {} + ), + ] + expected = {"n_docs": 2, "precision": 0.05, "recall": 0.45, "f-score": 0.5} + result = compute_document_metrics(alignments, mock_scorer, show_progress=False) + assert result == expected + assert mock_scorer.call_count == 2 + mock_scorer.assert_has_calls([call(a) for a in alignments]) + assert mock_precision.call_count == 2 + mock_precision.assert_has_calls([call(0, "score"), call(1, "score")]) + assert mock_recall.call_count == 2 + mock_recall.assert_has_calls([call(0, "score"), call(2, "score")]) + assert mock_fscore.call_count == 2 + mock_fscore.assert_has_calls([call(1, 0.0, 0.4), call(1, 0.1, 0.5)]) + + # Typical case but using F-0.5 score (beta=0.5) + mock_scorer.reset_mock() + mock_precision.reset_mock() + mock_recall.reset_mock() + mock_fscore.reset_mock() + mock_precision.side_effect = [0.0, 0.1, 0.2] + mock_recall.side_effect = [0.4, 0.5, 0.45] + mock_fscore.side_effect = [0, 1, 0.2] + alignments.append( + # n_ref_spans = 2, n_sys_spans = 5 + SpanAlignment( + DocSpans("c", [Span(0, 1), Span(1, 2)]), + DocSpans("c", [Span(0, 1), Span(1, 2), Span(2, 3), Span(3, 4), Span(4, 5)]), + {}, + ) + ) + expected = { + "n_docs": 3, + "precision": pytest.approx(0.1), + "recall": 0.45, + "f-score": pytest.approx(0.4), + } + result = compute_document_metrics( + alignments, mock_scorer, beta=0.5, show_progress=False + ) + assert result == expected + assert mock_scorer.call_count == 3 + mock_scorer.assert_has_calls([call(a) for a in alignments]) + assert mock_precision.call_count == 3 + mock_precision.assert_has_calls( + [call(0, "score"), call(1, "score"), call(5, "score")] + ) + assert mock_recall.call_count == 3 + mock_recall.assert_has_calls([call(0, "score"), call(2, "score"), call(2, "score")]) + assert mock_fscore.call_count == 3 + mock_fscore.assert_has_calls( + [call(0.5, 0.0, 0.4), call(0.5, 0.1, 0.5), call(0.5, 0.2, 0.45)] + ) + + +@patch( + "spanerr.compute_metrics.compute_document_metrics", + autospec=True, + return_value="doc metrics", +) +@patch( + "spanerr.compute_metrics.compute_entity_metrics", + autospec=True, + return_value="entity metrics", +) +@patch( + "spanerr.compute_metrics.get_span_alignments", + autospec=True, + return_value="alignments", +) +def test_compute_macro_metrics(mock_alignments, mock_entity_metrics, mock_doc_metrics): + # Unknown macro level + with pytest.raises(ValueError, match="Unsupported macro level: unknown"): + compute_macro_metrics("ref", "sys", "unknown", "aligner", "scorer") + mock_alignments.assert_called_once_with("ref", "sys", "aligner") + mock_entity_metrics.assert_not_called() + mock_doc_metrics.assert_not_called() + + # Entity-level + mock_alignments.reset_mock() + result = compute_macro_metrics("ref", "sys", "entity", "aligner", "scorer") + assert result == "entity metrics" + mock_alignments.assert_called_once_with("ref", "sys", "aligner") + mock_entity_metrics.assert_called_once_with( + "alignments", "scorer", beta=1, show_progress=True + ) + mock_doc_metrics.assert_not_called() + ## Setting optional parameters + mock_alignments.reset_mock() + mock_entity_metrics.reset_mock() + assert compute_macro_metrics( + "ref", "sys", "entity", "aligner", "scorer", beta="float", show_progress="bool" + ) + mock_alignments.assert_called_once_with("ref", "sys", "aligner") + mock_entity_metrics.assert_called_once_with( + "alignments", "scorer", beta="float", show_progress="bool" + ) + mock_doc_metrics.assert_not_called() + + # Document-level + mock_alignments.reset_mock() + mock_entity_metrics.reset_mock() + result = compute_macro_metrics("ref", "sys", "document", "aligner", "scorer") + assert result == "doc metrics" + mock_alignments.assert_called_once_with("ref", "sys", "aligner") + mock_doc_metrics.assert_called_once_with( + "alignments", "scorer", beta=1, show_progress=True + ) + mock_entity_metrics.assert_not_called() + ## Setting optional parameters + mock_alignments.reset_mock() + mock_doc_metrics.reset_mock() + assert compute_macro_metrics( + "ref", + "sys", + "document", + "aligner", + "scorer", + beta="float", + show_progress="bool", + ) + mock_alignments.assert_called_once_with("ref", "sys", "aligner") + mock_doc_metrics.assert_called_once_with( + "alignments", "scorer", beta="float", show_progress="bool" + ) + mock_entity_metrics.assert_not_called() + + +@pytest.mark.parametrize( + "cli_args,call_params", + [ + # all required params, default progress behavior + [ + [ + "compute_metrics.py", + "ref.jsonl", + "sys.jsonl", + "entity", + "select_first", + "overlap_factor", + ], + ( + [ + Path("ref.jsonl"), + Path("sys.jsonl"), + "entity", + "select_first", + "overlap_factor", + ], + {"show_progress": True}, + ), + ], + # disable progress + [ + [ + "compute_metrics.py", + "ref.jsonl", + "sys.jsonl", + "document", + "select_best", + "jaccard", + "--no-progress", + ], + ( + [ + Path("ref.jsonl"), + Path("sys.jsonl"), + "document", + "select_best", + "jaccard", + ], + {"show_progress": False}, + ), + ], + ], +) +@patch("spanerr.compute_metrics.get_scorer", return_value="scorer") +@patch("spanerr.compute_metrics.get_aligner", return_value="aligner") +@patch("spanerr.compute_metrics.compute_macro_metrics") +def test_main(mock_metrics, mock_aligner, mock_scorer, cli_args, call_params, capsys): + mock_metrics.return_value = { + "n_docs": 0, + "precision": 0.1, + "recall": 0.1, + "f-score": 0.1, + } + # patch in test args for argpars to parse + with patch("sys.argv", cli_args): + main() + args, kwargs = call_params + mock_aligner.assert_called_once_with(args[3]) + mock_scorer.assert_called_once_with(args[4]) + # Swap final args for the expected return values (based on patching) + args[3] = "aligner" + args[4] = "scorer" + mock_metrics.assert_called_once_with(*args, **kwargs) + # Check stdout output + captured = capsys.readouterr() + expected_reporting = "\n".join( + [ + f"Macro {args[2]}-level metrics for 0 documents:", + "- Precision = 0.1", + "- Recall = 0.1", + "- F1 = 0.1", + ] + ) + progress_pfx = "\n" if kwargs["show_progress"] else "" + assert captured.out == f"{progress_pfx}{expected_reporting}\n" diff --git a/tests/test_core.py b/tests/test_core.py index 3eee808..6a6d7f5 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -238,7 +238,7 @@ def test_spans(self): spans = d.spans spans.append(Span(7, 11)) assert spans == [Span(1, 3), Span(7, 11)] - assert d._spans == [Span(1, 3)] # check attribute unmodified + assert d._spans == (Span(1, 3),) # check attribute unmodified assert d.spans == [Span(1, 3)] # check view unmodified def test_aggregate(self): diff --git a/tests/test_data/ref.jsonl b/tests/test_data/ref.jsonl new file mode 100644 index 0000000..0890084 --- /dev/null +++ b/tests/test_data/ref.jsonl @@ -0,0 +1,9 @@ +{"doc_id":"no_matches","spans":[{"start":0,"end":1,"label":"a"},{"start":2,"end":3,"label":"b"}]} +{"doc_id":"empty","spans":[]} +{"doc_id":"exclusive_1","spans":[{"start":0,"end":4,"label":"a"},{"start":6,"end":8,"label":"b"},{"start":10,"end":15,"label":"c"}]} +{"doc_id":"exclusive_2","spans":[{"start":0,"end":4,"label":"a"},{"start":6,"end":8,"label":"b"},{"start":10,"end":15,"label":"c"}]} +{"doc_id":"exclusive_3","spans":[{"start":0,"end":4,"label":"a"},{"start":6,"end":8,"label":"b"},{"start":10,"end":15,"label":"c"}]} +{"doc_id":"exclusive_4","spans":[{"start":0,"end":4},{"start":5,"end":10},{"start":11,"end":15}]} +{"doc_id":"multi_1","spans":[{"start":0,"end":3},{"start":3,"end":6},{"start":8,"end":15}]} +{"doc_id":"multi_2","spans":[{"start":3,"end":8,"label":"a"}]} +{"doc_id":"gamma_14","spans":[{"start":2,"end":17,"label":"1"},{"start":22,"end":37,"label":"1"},{"start":58,"end":68,"label":"2"},{"start":70,"end":75,"label":"4"}]} diff --git a/tests/test_data/sys.jsonl b/tests/test_data/sys.jsonl new file mode 100644 index 0000000..c6659bb --- /dev/null +++ b/tests/test_data/sys.jsonl @@ -0,0 +1,9 @@ +{"doc_id":"no_matches","spans":[{"start":4,"end":5,"label":"c"},{"start":5,"end":6,"label":"d"},{"start":6,"end":7,"label":"e"}]} +{"doc_id":"empty","spans":[]} +{"doc_id":"exclusive_1","spans":[{"start":0,"end":4,"label":"a"},{"start":6,"end":8,"label":"b"},{"start":10,"end":15,"label":"c"}]} +{"doc_id":"exclusive_2","spans":[{"start":0,"end":4,"label":"d"},{"start":6,"end":8,"label":"e"},{"start":10,"end":15,"label":"f"}]} +{"doc_id":"exclusive_3","spans":[{"start":1,"end":3,"label":"a"},{"start":5,"end":7,"label":"b"},{"start":13,"end":20,"label":"c"}]} +{"doc_id":"exclusive_4","spans":[{"start":0,"end":6},{"start":7,"end":8},{"start":9,"end":15}]} +{"doc_id":"multi_1","spans":[{"start":4,"end":10}]} +{"doc_id":"multi_2","spans":[{"start":1,"end":4,"label":"a"},{"start":4,"end":7,"label":"a"},{"start":7,"end":10,"label":"a"},{"start":3,"end":8,"label":"c"}]} +{"doc_id":"gamma_14","spans":[{"start":2,"end":17,"label":"1"},{"start":32,"end":45,"label":"3"},{"start":58,"end":63,"label":"1"},{"start":70,"end":75,"label":"4"}]} diff --git a/tests/test_eval.py b/tests/test_eval.py new file mode 100644 index 0000000..88dd5bf --- /dev/null +++ b/tests/test_eval.py @@ -0,0 +1,140 @@ +import re +from unittest.mock import Mock, call, patch + +import pytest + +from spanerr.core import Span, SpanAlignment +from spanerr.eval import ( + f_beta, + is_one_to_one, + precision, + recall, + relevance_score, +) +from spanerr.span_utils import ( + CheckSpanPair, + ScoreSpanPair, + min_overlap_length, + partial_overlap, +) + + +def test_is_one_to_one(): + mock_alignment = Mock(spec=SpanAlignment) + # one-to-one + mapping = {Span(1, 2): [Span(2, 3)], Span(3, 4): [Span(4, 5)]} + rev_mapping = {Span(2, 3): [Span(1, 2)], Span(4, 5): [Span(3, 4)]} + mock_alignment.mapping = mapping + mock_alignment.reverse_mapping = rev_mapping + assert is_one_to_one(mock_alignment) + # a reference span maps to multiple system spans + mapping[Span(1, 2)].append(Span(5, 6)) + rev_mapping[Span(5, 6)] = [Span(1, 2)] + assert not is_one_to_one(mock_alignment) + # a system span maps to multiple reference spans + mock_alignment.mapping = rev_mapping + mock_alignment.reverse_mapping = mapping + assert not is_one_to_one(mock_alignment) + + +@patch("spanerr.eval.is_one_to_one", autospec=True) +def test_relevance_score(mock_121): + # initialize mock objects + mock_align = Mock(spec=SpanAlignment) + mock_score = Mock(spec=ScoreSpanPair, side_effect=Span.jaccard) + # Bad weight + err_msg = "Partial weight must be between 0 and 1 (inclusive)" + for w in [-0.5, 1.5]: + with pytest.raises(ValueError, match=re.escape(err_msg)): + relevance_score(mock_align, mock_score, partial_weight=w) + mock_score.assert_not_called() + # Unsupported alignment (does not have a one-to-one mapping) + mock_121.return_value = False + with pytest.raises(ValueError, match="Unsupported alignment"): + relevance_score(mock_align, mock_score) + mock_121.assert_called_once_with(mock_align) + mock_score.assert_not_called() + # Default case with supported alignment + ## No matches + mock_121.reset_mock() + mock_121.return_value = True + mock_align.mapping = {} + assert relevance_score(mock_align, mock_score) == 0 + mock_121.assert_called_once_with(mock_align) + mock_score.assert_not_called() + ## Only exact matches + exact_map = {Span(1, 3, "a"): [Span(1, 3, "a")], Span(3, 5, "b"): [Span(3, 5, "b")]} + mock_align.mapping = exact_map + assert relevance_score(mock_align, mock_score) == 2 + mock_score.assert_not_called() + ## With partial matches + partial_map = {Span(6, 10): [Span(7, 10)], Span(15, 20, "c"): [Span(18, 23, "c")]} + mock_align.mapping = partial_map + assert relevance_score(mock_align, mock_score) == 0.75 + 0.25 + assert mock_score.call_count == 2 + mock_score.assert_has_calls([call(key, val[0]) for key, val in partial_map.items()]) + ## Both exact and partial matches + mock_score.reset_mock() + both_map = exact_map | partial_map + mock_align.mapping = both_map + assert relevance_score(mock_align, mock_score) == 3 + assert mock_score.call_count == 2 + mock_score.assert_has_calls([call(key, val[0]) for key, val in partial_map.items()]) + # Customize partial weight + assert relevance_score(mock_align, mock_score, partial_weight=0.5) == 2.5 + # Customize exact match + mock_is_exact = Mock(spec=CheckSpanPair) + ## Treat all partial matches as exact matches + mock_score.reset_mock() + mock_is_exact.side_effect = partial_overlap + assert relevance_score(mock_align, mock_score, is_exact_match=mock_is_exact) == 4 + mock_score.assert_not_called() + assert mock_is_exact.call_count == 4 + mock_is_exact.assert_has_calls([call(key, val[0]) for key, val in both_map.items()]) + ## Treat all partial matches with overlap length >= 3 as exact matches + mock_is_exact.reset_mock() + mock_is_exact.side_effect = lambda a, b: min_overlap_length(a, b, 3) + assert relevance_score(mock_align, mock_score, is_exact_match=mock_is_exact) == 3.25 + + +@pytest.mark.parametrize( + "n_spans,rel_score,expected", + [(0, 0, 1), (10, 0, 0), (4, 2, 0.5), (4, 4, 1.0), (12, 9, 0.75)], +) +def test_precision_recall(n_spans, rel_score, expected): + assert precision(n_spans, rel_score) == expected + assert recall(n_spans, rel_score) == expected + + +@pytest.mark.parametrize( + "beta,precision,recall,expected", + [ + # F-1 + (1, 0, 0, 0), + (1, 0, 0.5, 0), + (1, 0.2, 0, 0), + (1, 1, 1, 1), + (1, 0.5, 0.5, 0.5), + (1, 0.75, 0.5, 0.6), + (1, 0.5, 0.75, 0.6), + # F-0.5 + (0.5, 0, 0, 0), + (0.5, 0, 0.5, 0), + (0.5, 0.2, 0, 0), + (0.5, 1, 1, 1), + (0.5, 0.5, 0.5, 0.5), + (0.5, 0.75, 0.5, 0.681818), + (0.5, 0.5, 0.75, 0.535714), + # F-2 + (2, 0, 0, 0), + (2, 0, 0.5, 0), + (2, 0.2, 0, 0), + (2, 1, 1, 1), + (2, 0.5, 0.5, 0.5), + (2, 0.75, 0.5, 0.535714), + (2, 0.5, 0.75, 0.681818), + ], +) +def test_f_beta_recall(beta, precision, recall, expected): + # round to 6th decimal place + assert round(f_beta(beta, precision, recall), 6) == expected diff --git a/tests/test_span_utils.py b/tests/test_span_utils.py new file mode 100644 index 0000000..9abe65a --- /dev/null +++ b/tests/test_span_utils.py @@ -0,0 +1,113 @@ +from unittest.mock import Mock + +import pytest + +from spanerr.core import Span +from spanerr.span_utils import ( + ScoreLabelPair, + ScoreSpanPair, + composite_match_score, + exact_match, + min_overlap_factor, + min_overlap_length, + partial_overlap, +) + + +@pytest.mark.parametrize( + "span_a,span_b,expected", + [ + (Span(2, 4, "a"), Span(2, 4, "a"), True), + (Span(2, 4, "a"), Span(2, 4, "b"), False), + (Span(1, 4), Span(2, 3), False), + (Span(1, 4), Span(2, 3, "o"), False), + (Span(1, 3, "i"), Span(3, 5, "i"), False), + (Span(1, 3, "i"), Span(3, 5, "j"), False), + ], +) +def test_exact_match(span_a, span_b, expected): + assert exact_match(span_a, span_b) == expected + + +@pytest.mark.parametrize( + "span_a,span_b,expected", + [ + (Span(2, 4, "a"), Span(2, 4, "a"), True), + (Span(2, 4, "a"), Span(2, 4, "b"), False), + (Span(1, 4), Span(2, 3), True), + (Span(1, 4), Span(2, 3, "o"), False), + (Span(1, 3, "i"), Span(3, 5, "i"), False), + (Span(1, 3, "i"), Span(3, 5, "j"), False), + ], +) +def test_partial_overlap(span_a, span_b, expected): + assert partial_overlap(span_a, span_b) == expected + + +@pytest.mark.parametrize( + "span_a,span_b,min_val,expected", + [ + (Span(2, 4, "a"), Span(2, 4, "a"), 2, True), + (Span(2, 4, "a"), Span(2, 4, "b"), 2, False), + (Span(2, 4, "a"), Span(2, 4, "a"), 4, False), + (Span(2, 4, "a"), Span(2, 4, "b"), 4, False), + (Span(1, 4), Span(3, 5), 1, True), + (Span(1, 4), Span(3, 5, "o"), 1, False), + (Span(1, 4), Span(3, 5), 10, False), + (Span(1, 4), Span(3, 5, "o"), 10, False), + (Span(0, 2), Span(2, 4), 0, True), + (Span(0, 2), Span(2, 4, "o"), 0, False), + ], +) +def test_min_overlap_length(span_a, span_b, min_val, expected): + assert min_overlap_length(span_a, span_b, min_val) == expected + + +@pytest.mark.parametrize( + "span_a,span_b,min_val,expected", + [ + (Span(2, 4, "a"), Span(2, 4, "a"), 1, True), + (Span(2, 4, "a"), Span(2, 4, "b"), 1, False), + (Span(1, 4), Span(3, 5), 0.25, True), + (Span(1, 4), Span(3, 5, "o"), 0.25, False), + (Span(1, 4), Span(3, 5), 0.5, False), + (Span(1, 4), Span(3, 5, "o"), 0.5, False), + (Span(0, 2), Span(2, 4), 0, True), + (Span(0, 2), Span(2, 4, "o"), 0, False), + ], +) +def test_min_overlap_factor(span_a, span_b, min_val, expected): + # Label sensitive + assert min_overlap_factor(span_a, span_b, min_val) == expected + + +@pytest.mark.parametrize( + "span_a,span_b,expected_default, expected_custom", + [ + (Span(2, 4, "a"), Span(2, 4, "a"), 1, 1), + (Span(2, 4, "a"), Span(2, 4, "b"), 0, 0), + (Span(2, 4, "a"), Span(2, 4, "A"), 0, 1), + (Span(1, 4), Span(3, 5), 0.25, 0.25), + (Span(1, 4, "i"), Span(3, 5, "i"), 0.25, 0.25), + (Span(1, 4, "I"), Span(3, 5, "i"), 0, 0.25), + (Span(1, 4), Span(3, 5, "o"), 0, 0), + (Span(0, 2), Span(2, 4), 0, 0), + (Span(0, 2, "o"), Span(2, 4, "O"), 0, 0), + ], +) +def test_composite_match_score(span_a, span_b, expected_default, expected_custom): + mock_score_bounds = Mock(spec=ScoreSpanPair, side_effect=Span.jaccard) + # Default label scoring + assert composite_match_score(span_a, span_b, mock_score_bounds) == expected_default + mock_score_bounds.assert_called_once_with(span_a, span_b) + # Custom label scoring + mock_score_bounds.reset_mock() + mock_score_label = Mock( + spec=ScoreLabelPair, side_effect=lambda a, b: a.lower() == b.lower() + ) + result = composite_match_score( + span_a, span_b, mock_score_bounds, score_label=mock_score_label + ) + assert result == expected_custom + mock_score_label.assert_called_once_with(span_a.label, span_b.label) + mock_score_bounds.assert_called_once_with(span_a, span_b) diff --git a/tests/test_spans/test_match.py b/tests/test_spans/test_match.py deleted file mode 100644 index c8fae44..0000000 --- a/tests/test_spans/test_match.py +++ /dev/null @@ -1,91 +0,0 @@ -import pytest - -from spanerr.core import Span -from spanerr.spans.match import ( - exact_match, - min_overlap_factor, - min_overlap_length, - partial_overlap, -) - - -@pytest.mark.parametrize( - "span_a,span_b,expected_default,expected_flag", - [ - (Span(2, 4, "a"), Span(2, 4, "a"), True, True), - (Span(2, 4, "a"), Span(2, 4, "b"), False, True), - (Span(1, 4), Span(2, 3), False, False), - (Span(1, 4), Span(2, 3, "o"), False, False), - (Span(1, 3, "i"), Span(3, 5, "i"), False, False), - (Span(1, 3, "i"), Span(3, 5, "j"), False, False), - ], -) -def test_exact_match(span_a, span_b, expected_default, expected_flag): - # Default: Label sensitive - assert exact_match(span_a, span_b) == expected_default - # Label insensitive - assert exact_match(span_a, span_b, ignore_label=True) == expected_flag - - -@pytest.mark.parametrize( - "span_a,span_b,expected_default,expected_flag", - [ - (Span(2, 4, "a"), Span(2, 4, "a"), True, True), - (Span(2, 4, "a"), Span(2, 4, "b"), False, True), - (Span(1, 4), Span(2, 3), True, True), - (Span(1, 4), Span(2, 3, "o"), False, True), - (Span(1, 3, "i"), Span(3, 5, "i"), False, False), - (Span(1, 3, "i"), Span(3, 5, "j"), False, False), - ], -) -def test_partial_overlap(span_a, span_b, expected_default, expected_flag): - # Default: Label sensitive - assert partial_overlap(span_a, span_b) == expected_default - # Label insensitive - assert partial_overlap(span_a, span_b, ignore_label=True) == expected_flag - - -@pytest.mark.parametrize( - "span_a,span_b,min_val,expected_default,expected_flag", - [ - (Span(2, 4, "a"), Span(2, 4, "a"), 2, True, True), - (Span(2, 4, "a"), Span(2, 4, "b"), 2, False, True), - (Span(2, 4, "a"), Span(2, 4, "a"), 4, False, False), - (Span(2, 4, "a"), Span(2, 4, "b"), 4, False, False), - (Span(1, 4), Span(3, 5), 1, True, True), - (Span(1, 4), Span(3, 5, "o"), 1, False, True), - (Span(1, 4), Span(3, 5), 10, False, False), - (Span(1, 4), Span(3, 5, "o"), 10, False, False), - (Span(0, 2), Span(2, 4), 0, True, True), - (Span(0, 2), Span(2, 4, "o"), 0, False, True), - ], -) -def test_min_overlap_length(span_a, span_b, min_val, expected_default, expected_flag): - # Label sensitive - assert min_overlap_length(span_a, span_b, min_val) == expected_default - # Label insensitive - assert ( - min_overlap_length(span_a, span_b, min_val, ignore_label=True) == expected_flag - ) - - -@pytest.mark.parametrize( - "span_a,span_b,min_val,expected_default,expected_flag", - [ - (Span(2, 4, "a"), Span(2, 4, "a"), 1, True, True), - (Span(2, 4, "a"), Span(2, 4, "b"), 1, False, True), - (Span(1, 4), Span(3, 5), 0.25, True, True), - (Span(1, 4), Span(3, 5, "o"), 0.25, False, True), - (Span(1, 4), Span(3, 5), 0.5, False, False), - (Span(1, 4), Span(3, 5, "o"), 0.5, False, False), - (Span(0, 2), Span(2, 4), 0, True, True), - (Span(0, 2), Span(2, 4, "o"), 0, False, True), - ], -) -def test_min_overlap_factor(span_a, span_b, min_val, expected_default, expected_flag): - # Label sensitive - assert min_overlap_factor(span_a, span_b, min_val) == expected_default - # Label insensitive - assert ( - min_overlap_factor(span_a, span_b, min_val, ignore_label=True) == expected_flag - ) diff --git a/uv.lock b/uv.lock index f866291..3a8ea00 100644 --- a/uv.lock +++ b/uv.lock @@ -3,9 +3,56 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-08-19T17:55:54.902732Z" +exclude-newer = "2026-09-01T18:06:14.572133Z" exclude-newer-span = "P1W" +[[package]] +name = "backports-zstd" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/f0/9ba1b05811aa5f5434f69768253129460a5744e1814f359efba39a01ce20/backports_zstd-1.7.0.tar.gz", hash = "sha256:1a967189c1822b6e85a2e550fdfc88a3272c17633ea0a4732dac5911a8034f2b", size = 1003722, upload-time = "2026-08-15T17:26:43.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/23/240495dec973dcfb34816248956ca8d05b32fb75936c226c1cf497b83b83/backports_zstd-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5548a857bb0fcc5449cc3687353547396c6b1ecd4bd882f1cd34fa8d29e70ca", size = 439047, upload-time = "2026-08-15T17:25:38.084Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5556959c7d03bfee5ff14d7f07dd9bf8de737c69f81d823a32784ab39c34/backports_zstd-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bab192b934fdf5a03df4752556d9c8af2d058163fdfbafd4a253cdfe25449a6f", size = 367666, upload-time = "2026-08-15T17:25:39.233Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/557db98001c4a7202beed19e8bd42603a2315b80fd5def7e21a0b048ec3b/backports_zstd-1.7.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:8344260bed9842c415a93d9bfe23ea834e5f27758827d56933d8c0d06db507a2", size = 508475, upload-time = "2026-08-15T17:25:40.367Z" }, + { url = "https://files.pythonhosted.org/packages/40/4b/820acbc2c1d1d945aedca0c0d22546a948630ffb186df523098fbd669a95/backports_zstd-1.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c55e55e1e9dee312bc5e186386e6aa5207482a6d2242bd7c14709ded254f87f", size = 478240, upload-time = "2026-08-15T17:25:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/f9/76/77fa9b385e79d4c106ce15d66681978f39a844b0eb5db02682687246b716/backports_zstd-1.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cf609af3735c7e697ccd13f6b0c88da57c201b6ea63c6afbfe81d6f9b50e298c", size = 583611, upload-time = "2026-08-15T17:25:43.104Z" }, + { url = "https://files.pythonhosted.org/packages/95/a4/fbb7c73336f3279dad36da94382a59755100b656301ea836ebaa42736581/backports_zstd-1.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:676a37971f676830d4f90cee8fdf4e438781596fb2f2d1984ac76c9b3eb39a69", size = 642496, upload-time = "2026-08-15T17:25:44.322Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/121917bd2671bc3f1507c25503c0554f0b52483edcca4e6210e6d22228df/backports_zstd-1.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:470895d0bcddc850766e593d1b26764fb138c2feed149f515a2627ef9587d54c", size = 496195, upload-time = "2026-08-15T17:25:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/de/c7/c6379a0d734bea1c7f14d07c23258108cc92b994654e25cfe3a3e88cd785/backports_zstd-1.7.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02f2f6649a342d0901ddb35596ddadb7c3bb1cf6bb54d691e5e0285f1fa0674f", size = 570964, upload-time = "2026-08-15T17:25:46.648Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a4/372c3dd3017c3f93cda0acbc282f8073b70efdc1b56d1fdeebe023660725/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:132ba81fad59d44958b7d10da31545e7128c469cfbc2e268d0eaab96daa64175", size = 484276, upload-time = "2026-08-15T17:25:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/83fa7bdd5e1d808203b9143848fdf7e15de399b8119a0d4378b2aea9be78/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a3e1c6ce0b232ee6703ed24ee126e8186107f5a4e56edbd21cd1aa5a8c6bfd12", size = 511963, upload-time = "2026-08-15T17:25:49.666Z" }, + { url = "https://files.pythonhosted.org/packages/56/d2/d4ed32c353148acc18f3b665ab24a677b9c49d3640244424c5d6046400c5/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d7a7cb964eb8d1bb5d039970b16fe54802ea47dc935ae96d9874844a126bf8ff", size = 588025, upload-time = "2026-08-15T17:25:51.288Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5a/df8b5b848e8dfdec6edca55f22067ffbafa081d81aec1313e28155c3fea3/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:12a9842a2ec2854cbec7f252ab29d44c2b772788a9bbafded743ca4bf73b115f", size = 568223, upload-time = "2026-08-15T17:25:52.633Z" }, + { url = "https://files.pythonhosted.org/packages/43/8c/f970f15e7fdbf8a251f121c91364fa68bbc2dfab4d5eca058427dec63397/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:138154eea8ced84394991bf0e819dba6b690306a178dd528c28eee724b7d4aec", size = 632937, upload-time = "2026-08-15T17:25:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d0/e36c18c87a74421954502d123ff7027e0a63a7624dffa99ec0f7474deff9/backports_zstd-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:468b636ed365627b364c94be1c35a52858e13b5bc1fa3f068bbc71b1af65f3d7", size = 500735, upload-time = "2026-08-15T17:25:56.064Z" }, + { url = "https://files.pythonhosted.org/packages/1c/57/fc72280334d2aa94238c5882052263bd7796c1fa924044353c30d058e0c3/backports_zstd-1.7.0-cp312-cp312-win32.whl", hash = "sha256:f026fe2e89b7ff01ba6ebec6abaff34c6063919151a32afb68714cf139e17c50", size = 292394, upload-time = "2026-08-15T17:25:57.469Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/6cea747bdeef34cd12482b17e604b832fdb0962987132b99496f1a6c3f82/backports_zstd-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:2ea62ba2f1a6e6c9e6dc108921f9ae881969ca72e073162fa488d0de3eb2713f", size = 329876, upload-time = "2026-08-15T17:25:58.798Z" }, + { url = "https://files.pythonhosted.org/packages/64/28/b4a17c07d5a50a45cb04592960d1593cdf3b3728968371f332aa3643b804/backports_zstd-1.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:cefb983345c55ccaa20423a4eb96434730e6d640ffa2db9b60e5bedb0fbef94e", size = 292461, upload-time = "2026-08-15T17:25:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/6f/24/32b3358ae3a4df0ebad85ebbce721818c6d76a836119bee76089d103e951/backports_zstd-1.7.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:a3fbcbf819bee2b06b8666b13742098d0f40663ee34e64a12bc360ec0f5e3d89", size = 400913, upload-time = "2026-08-15T17:26:01.089Z" }, + { url = "https://files.pythonhosted.org/packages/af/f3/39ef7dd75eb1e699e25a19212737a73d3c030a0c9fd1d0ed1572b5f8e493/backports_zstd-1.7.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:efee02f18e04c2e9e6d694c5cf9b7457c4bda3ea96f48b1ee69769e06bb9d89f", size = 454915, upload-time = "2026-08-15T17:26:02.294Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/8209081e094aa98b2f28bac388619c85b1a44aed813d6b3c54d1da79d19a/backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ecc95fa0e91d92951d74468e7789afdf91d9e702f40af2d0fcbf0ded4d0f650a", size = 357992, upload-time = "2026-08-15T17:26:03.552Z" }, + { url = "https://files.pythonhosted.org/packages/b3/65/64025302bae4ba924d613e404c6120bf194b5636786960ece274622a4a3e/backports_zstd-1.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34154d82fc0246738159084d146401073f9ac9cfd755b66bb8853ca06037810c", size = 366686, upload-time = "2026-08-15T17:26:04.812Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b9/c4d24d113d28b774662152c462d38d28109741d6d45c1aea7834371741dc/backports_zstd-1.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44b687b1c0be5cb279693d2682f91ff84c559d679b2ef2fbe501fe4b2db2c4bb", size = 447221, upload-time = "2026-08-15T17:26:05.979Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/8db55c7f77aec60879844a879ac026065d8f03aab74080701acc060c4168/backports_zstd-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dcdbd368659f46b570114eeea36b75347716523870d71f6bc5d7801862aefd6e", size = 438571, upload-time = "2026-08-15T17:26:07.421Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f8/72930ae4bb7bf6b9d6c7c31bce7b3e5751c062269a4ee718066e25f1973b/backports_zstd-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eda97fa535d4651a4ccdeed4ee7dde3978369046abc8a7456a7117d4271f9333", size = 367041, upload-time = "2026-08-15T17:26:08.537Z" }, + { url = "https://files.pythonhosted.org/packages/17/9b/7289dc191b34279d8f176bf5b181c3b26f8e049d14a2c0a2637650f034e5/backports_zstd-1.7.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7e3999b5141d7f85171822d06112f70f7f317d162f0120530dd2c7a28dbf8add", size = 507676, upload-time = "2026-08-15T17:26:09.909Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4d/6dd730b79ab96532e23fe851003545b4cc79e50c5b4c79ffcbe1b724eec4/backports_zstd-1.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69367726f4075c2574746f5883b0dc045805c5b02a81fdf8c829c26d33969de3", size = 477744, upload-time = "2026-08-15T17:26:11.038Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/11687e5019d56ea47893cf2ba59a6b4884a4e2d1496d0e653aed373b973f/backports_zstd-1.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15e97edfd173ade365c01bac7d9d297fa906686015cdbcb5f32a0d410887826b", size = 583215, upload-time = "2026-08-15T17:26:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c7/5a8c58542469ab31680c403b844770c119a976fd4cf1000fd7d53e7d0f77/backports_zstd-1.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:32a94cdcf16b44395cd55086ea38877395ca6bf3362cb507b0eb86db2a45a6a4", size = 644125, upload-time = "2026-08-15T17:26:13.651Z" }, + { url = "https://files.pythonhosted.org/packages/11/35/be5485e65df95b86c4981ad4a577b505cfeec6b700a46a86e2e3175ac718/backports_zstd-1.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3f4887a8a1fd1290017fe5a1d29a7d1dc5c57f9477fbd64f119316a7e3ae769", size = 492714, upload-time = "2026-08-15T17:26:14.838Z" }, + { url = "https://files.pythonhosted.org/packages/96/8b/a0603458ca08e4a56f09ae58588ce3c0453425e753df704d9aeaabb66ae5/backports_zstd-1.7.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e590313ce156f1d8986dff3107e8ed1651d6d106a56b3a95f965ff8d845ba979", size = 567953, upload-time = "2026-08-15T17:26:16.276Z" }, + { url = "https://files.pythonhosted.org/packages/02/87/2296db4c3c578947c35ccd8dcdf7992316d7e1f5f43cc829c062b3ed9319/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:565270b0d6497970fa97a0df59593ae0d225e4176678bbce851d39e5f8aa422b", size = 483564, upload-time = "2026-08-15T17:26:17.493Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d8/f53a79e6bf3cdb7ae08f95220c80bd0d606f3d6c3482995deaf21d024fb9/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:37ef23c6c522fe935726c8fba6344350973c4a23b06d10194d90d0868b09ff7a", size = 511193, upload-time = "2026-08-15T17:26:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/31/ea/d4e2eb159cd5813debd5a34d0644caff5fe7cf2e569bf5b02a82934aeee7/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b3975330159f1efdd1fba76afe1c7b84f66f26e2bf209b32630fb148d647e0d5", size = 587748, upload-time = "2026-08-15T17:26:20.148Z" }, + { url = "https://files.pythonhosted.org/packages/81/d2/b5ec9709660fb1c193508215d9c30e781fac406183faac7c3c36b1c583a9/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b40bc8cd0a86cbbe8263a9c3a2bf2e34897483516c6d799725412a19524c32e3", size = 565808, upload-time = "2026-08-15T17:26:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/bd/13/004735cc4536483cbd973a60346a9dbc7bb977b13c28b55a11da14bb0a1e/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f37e12ef10747f76901b1f20ef70d33221e861de177dba5ba08552242c6fd4bd", size = 634520, upload-time = "2026-08-15T17:26:22.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/28/05b11f7084d1100491cf7c60962aafd900c3dd01b1fc1ce85914476cdae0/backports_zstd-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5992143b2a8b71b4d17afed20cce2df50f8718228e31d6e716493b1fe9201712", size = 497098, upload-time = "2026-08-15T17:26:24.181Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a5/bdc98d039ddbd5815fc1dd71912bbfb9f820a46ec12004ead51c8d60ea50/backports_zstd-1.7.0-cp313-cp313-win32.whl", hash = "sha256:31ae30d216ffae9243dfa607bcb995f94a70de5765bb8fae1e35ea1ad6497959", size = 291974, upload-time = "2026-08-15T17:26:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/13/7d/fb0da7351e8b152d5149127594972922829281c316618df37a7e724f2eb9/backports_zstd-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:8086b4a7443bb2863f7ef8edb317b715d5f3ccec6c5512619bd23d57661ba1b7", size = 329550, upload-time = "2026-08-15T17:26:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/37/f9/109ac272d650483fbdfa611c0040253a405f640604fbc90acc8076c6d37f/backports_zstd-1.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:7eaceeec75e1dbdce40b81fb0ed1ffdb7ce492d970db7f8aabd6a95ccd6c3dd3", size = 292174, upload-time = "2026-08-15T17:26:27.819Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -159,6 +206,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isal" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/35/40ff3eabd401036f792cf55ba9cd19dcd5e3cb79aa5798332885ab0ff1b9/isal-1.8.0.tar.gz", hash = "sha256:124233e9a31a62030a07aafd48c26689561926f4e10417ed3ea46c211218f2b4", size = 4133365, upload-time = "2025-09-10T08:47:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/6f/e170e758293712e4f7ac1d0cf92290a80816d0eea8eb0871d82877ca7372/isal-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3255b5dd6ac0238d410a6d630761e3826d4360400e88d6106e8ad85fe9042966", size = 237652, upload-time = "2025-09-10T08:47:31.57Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9b/0c3f5fc05aa7d67dc1aa9542549c044234e2d6abd8a2b39f5f689ab9b612/isal-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2147175ea74b9028653c5949b7e1b241e2e24f017879fb55d52de9496786d9d8", size = 189145, upload-time = "2025-09-10T08:43:20.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/1ef86dd9419a0ab350a4dc0078c0ca7e5d9d96dea2978361d1d2cde22084/isal-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa279aa6b7d6b6e99cceab84f7a8d53e755d2954ad95e14548e94460b7f4c0f2", size = 234403, upload-time = "2025-09-10T09:13:11.214Z" }, + { url = "https://files.pythonhosted.org/packages/29/92/c10343738c170c31a5e25f0a1d024f8160ec107c5a2935a1a07587821100/isal-1.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3c28ff61f2f300e498ea0f50cb1528d8c14631fce4cdfce191ed05775952de3", size = 264663, upload-time = "2025-09-10T08:47:01.294Z" }, + { url = "https://files.pythonhosted.org/packages/31/4f/fec324c58eeb607bcc1716a555d4a161c9a0815060ef13e229b1f28b9836/isal-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ba19300d922ba6bc2305e7548c4a27266061448df526bd660ceaaeead500c694", size = 235142, upload-time = "2025-09-10T09:13:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/9f/72/5cbc30d59821bcf93be44eab758ca999794fbd6e47b67954193d11e92000/isal-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ce55960f53603145d35188ca6363848b79675d81c95a3ff2cfb4b2cb806873e", size = 266327, upload-time = "2025-09-10T08:47:02.178Z" }, + { url = "https://files.pythonhosted.org/packages/63/a0/3cdaac7caab7e5e2660afbf03d16616f8c3fb91ec3b75596e2388d42b90b/isal-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d376b7644434d50fedfb670483150ece64082212b6e1f23976f92a91fa1b99b", size = 203025, upload-time = "2025-09-10T08:49:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6b/11966680b6cdb040359901b8df235f5a7948c1104e38e0441e319f1e6365/isal-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f9072de73d7e896f3785f1e5df7859d051424f17aa678a86f6e204c2f653b3ef", size = 237633, upload-time = "2025-09-10T08:47:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/f1/22/232e516b2de02ce6c7c007e5dcf78f0bd854bd4d4e761fe6a409f2571ccb/isal-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57baeb782f14714adab7990402fe965f11f88c7de9456de3c5426c378c476de3", size = 189131, upload-time = "2025-09-10T08:43:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/db/ff/b438cc054270f5fbea38f0f88185a8b696db6022029995bc301fd924ab38/isal-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ced06c2e71028fc6755edec6a9de4f1f680fdc7dd22497de3118729043e8f28", size = 234376, upload-time = "2025-09-10T09:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/47188fb4988456f750faeac1b5e656bea225eb44567344c5bb8c22dce620/isal-1.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df4550061cbc828def0e19f7cf59c8dfe8d585869bd33ed4c5ddf6f1c477f640", size = 264678, upload-time = "2025-09-10T08:47:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/d1/ecef8dd3faf1c781fc53ada5266200254373e1b24c207ce237f8de6baa0e/isal-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5461b34053badb6a555601e39130a4e7d801e32d5c745adba2ed1ffe50583a8b", size = 235139, upload-time = "2025-09-10T09:13:14.162Z" }, + { url = "https://files.pythonhosted.org/packages/91/d2/bb46cb0cc0bf5ffdb55c970c7aa161b8188f63e320ab923501d4030d7f7a/isal-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2c91bc9d0421fdf86b3a377cef6b9c58e84104e3d5b69dd02a83ca8190823153", size = 266294, upload-time = "2025-09-10T08:47:04.242Z" }, + { url = "https://files.pythonhosted.org/packages/2f/56/932cf1d1471e74ea8b21958cbbcc98f49a49251de5f629c292fce02fa51b/isal-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:e1b2118cdc4b4813f679d6b941ec3f9db8d433c260df02fbc5fc6e2a007457b8", size = 202996, upload-time = "2025-09-10T08:49:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e0/3ffd41f69d3259344a0ee763dfb39521798ae2a4221e14a3a7f4e47f38a1/isal-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:272293b48fdd50b86b5c19fbae8b5938aad2efa1768d3ef66f070269c0420261", size = 237612, upload-time = "2025-09-10T08:47:33.369Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/64829ef22e42772f940ae1c74a36c0e837157a2065960047e2e8eab22da8/isal-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:26496d4dcc1bd473c0a0fd9302c6e97d994741a5109590afade60fb9896270da", size = 189161, upload-time = "2025-09-10T08:43:23.101Z" }, + { url = "https://files.pythonhosted.org/packages/1a/63/c43f1134f1c000355435d2347a3afdf2105e957958e0209edcd613d6531d/isal-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65695e42335249503b4af05773d556d01c2d6906473606b0d144f4aa03bf41dd", size = 234440, upload-time = "2025-09-10T09:13:15.153Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/0bebab1f4c6e4503bd52e2a9871f41e197bea1f87b7bcaa60dc513f67998/isal-1.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7228932f08622d0463777106fcdc29d1ddc53900dd05257eea2c6a59094f6a", size = 264691, upload-time = "2025-09-10T08:47:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/46/5f/f63af7a4687095d8c286fecb0b6b1dc4857bcffa7adad1014a8935f31002/isal-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2204027a4cca57815ead299976c8afc94fae18ffb9287d5771d01cc907899ee", size = 235199, upload-time = "2025-09-10T09:13:16.123Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d3/d2155f41d7f77fbdd97815c483a9c289ef0fe470da7cf4444c9950e67b0e/isal-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f437ea6b084343711e9f80245392b73dfdd7e7ed9d3555a3be399f05538217a7", size = 266305, upload-time = "2025-09-10T08:47:06.694Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/46e2f69228cb60ae7150d87154018d4229dea91e59dab73df30d4024a075/isal-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:1f4349bc7eb446977e9977d6c746e0a7b7089a34f234780c7636da525227a421", size = 208258, upload-time = "2025-09-10T08:49:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2f/61df3b1768c923be7a35c6388154ddebd5a3c3e4880ac2942b8737cc95d1/isal-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f2bc7f828f93db859d05b20658389917082dadff91d10e097e493b68a24b2f23", size = 238612, upload-time = "2025-09-10T08:47:34.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/41/3d885d62929439bfc344afb414e7702475e16cbc16fbf5e9f3609f34d6c5/isal-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8778153b53f36db545671c077a8f20734f7d34d7bdbc521bbe197aabfc6358d2", size = 190499, upload-time = "2025-09-10T08:43:24.353Z" }, + { url = "https://files.pythonhosted.org/packages/52/45/5ab58528dc47278898758a8a0c4813f00b519fef7b1d24431fa01185df79/isal-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0adc3d7354f79a25bd7c20a42d6a257ff9ade54b709b40a5ce05f0eb7085134", size = 236048, upload-time = "2025-09-10T09:13:17.117Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ec/21416397eb988435786ab748fdabdb205854c0bdc618e2bcb797ffc811a0/isal-1.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31662c3939b5653e29770e78eacf399dee8082486a3033c52e139108ee7f8767", size = 265915, upload-time = "2025-09-10T08:47:07.702Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c6/a19dd99ae36a28c984aaeb77e06dedaac0d0d413c40792e37461fe0a228a/isal-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4f46ec4289e8dc74777a0199528f612f2b8aecd9f60a932990a4f66062bc509", size = 236583, upload-time = "2025-09-10T09:13:18.179Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/47ee5ec9b9b67a792225895fb4683a1e3c721e8fe0a4d79d2822e43e4c59/isal-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:914442a3da17812fc5ab136da6aad2c5cee59d17bb9382b59f7a55efeea28988", size = 267585, upload-time = "2025-09-10T08:47:08.928Z" }, + { url = "https://files.pythonhosted.org/packages/e0/8a/768d91b6078f283c521b79e0a59d7e07a54a0bfab690ab90bcf4c641cc93/isal-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e76946e7455b1614a6a00bf9ec6444baa3a5217e6806836e0e9a271f0d18f84d", size = 209399, upload-time = "2025-09-10T08:49:19.2Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -168,6 +251,71 @@ 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 = "orjson" +version = "3.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" }, + { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" }, + { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" }, + { url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" }, + { url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" }, + { url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" }, + { url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" }, + { url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" }, + { url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" }, + { url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" }, + { url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" }, + { url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, +] + +[[package]] +name = "orjsonl" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orjson" }, + { name = "xopen" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/6e/aba71f429cdfc141789afa9ea64d86b4277200a7efd4a3e8a025394178bc/orjsonl-1.0.0.tar.gz", hash = "sha256:5097e7d099a0700a173dbabd90285245442b7940e52386b1470ca1678125e763", size = 7880, upload-time = "2023-11-09T02:27:53.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/9f/4c2899436bcc758b7c0cfa4012ea2a4983601f7580f9926ca10f79f8dbc8/orjsonl-1.0.0-py3-none-any.whl", hash = "sha256:21f5688517a34ae77cd919dac63e11eb103b75da9be60ea910ac4f6862d92a47", size = 5870, upload-time = "2023-11-09T02:27:52.001Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -337,6 +485,10 @@ wheels = [ name = "spanerr" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "orjsonl" }, + { name = "tqdm" }, +] [package.dev-dependencies] dev = [ @@ -354,6 +506,10 @@ test = [ ] [package.metadata] +requires-dist = [ + { name = "orjsonl", specifier = ">=1.0.0" }, + { name = "tqdm", specifier = ">=4.70.0" }, +] [package.metadata.requires-dev] dev = [ @@ -370,6 +526,18 @@ test = [ { name = "pytest-cov", specifier = ">=7.1.0" }, ] +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + [[package]] name = "ty" version = "0.0.73" @@ -409,3 +577,53 @@ sdist = { url = "https://files.pythonhosted.org/packages/2d/dc/a6eb1ddfa7f1e390f wheels = [ { url = "https://files.pythonhosted.org/packages/40/4c/eb2f52aeeaf30dbd073d315a251a63ae2b8263171ec4428c135140cb0802/virtualenv-21.7.4-py3-none-any.whl", hash = "sha256:376ec93cd6aab3044fa395d7db226db38043b7b5748948044b2a87168525e843", size = 5324444, upload-time = "2026-08-10T22:54:31.515Z" }, ] + +[[package]] +name = "xopen" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, + { name = "isal", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "zlib-ng", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/bb/74a5222fc975fccb9f8bfb62613ca42a5a28008362c598df09806ecd12c1/xopen-2.1.0.tar.gz", hash = "sha256:06584821588f813563863b63d5e02568a4328661b7507ea38c432f24c69ea4b7", size = 32424, upload-time = "2026-06-03T05:54:07.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d7/f27df80071464ea76f092f77e6801534f36c83ab1c24efed885e36166cc1/xopen-2.1.0-py3-none-any.whl", hash = "sha256:0d01d2183e7496594cbed022b1d89a0a7f4e03900da1b8d59047322a5140a66d", size = 17120, upload-time = "2026-06-03T05:54:06.094Z" }, +] + +[[package]] +name = "zlib-ng" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/7d/901c6e333fb031b5bfbd1532099200cf859f12aa83689be494eade6685ec/zlib_ng-1.0.0.tar.gz", hash = "sha256:c753cea73f9e803c246e9bf01a59eb652897ed8a19334ada0f968394c7f61650", size = 5799954, upload-time = "2025-09-10T11:46:17.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/6f/ad3b032d3881a5f35d673b429a8a524d8cb2b56d81f8ca4194117a502509/zlib_ng-1.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d894ed89fd1f53344b8334333794f53d7119da034b49e08e39f0d2b05a1f699c", size = 108672, upload-time = "2025-09-10T11:45:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7c/67d4a0bb72039f8a8e11cd711aed63a0adf83961fea668e204b07d6f469d/zlib_ng-1.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c01e44613d9a4cc1f6f6dcfab03ae43fd3b4f9bd909006398c75fe4a1fb48333", size = 91299, upload-time = "2025-09-10T11:43:55.018Z" }, + { url = "https://files.pythonhosted.org/packages/50/97/9836a0ec483786803c1a9925f6249cbb5dbd408fcc100bd8b4cd615c012d/zlib_ng-1.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:611a85b2dcb206a3cf8cdaa4323dbf9dbefe6c92e83d2da86333050f33a4318e", size = 111319, upload-time = "2025-09-10T12:21:23.809Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/5baf549131c47cbf5a00c35c7db7a78d5aa3c405605255a1496160a96a87/zlib_ng-1.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5332f9452b2fc27e47a1ca78fc150689ed9c51c7f449a5467bf41c4b206c439f", size = 132407, upload-time = "2025-09-10T11:46:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/96/e6/6b09e61cfa205b546f3c8202be35795040340a12dde36dd990eac9747ef8/zlib_ng-1.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c362f54b67a4385b19ab8972b66f34da73b93c1b8f0b251a0f20d315c15f71a", size = 112144, upload-time = "2025-09-10T12:21:24.995Z" }, + { url = "https://files.pythonhosted.org/packages/5f/55/886fe76443fb7131a364a4ff3b257ac0c7bcf61d2562c009de5104a051d9/zlib_ng-1.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e1a1205e4146819f9c5dbaaa89be587fc7a09f06094676f2dc27146ba1682de5", size = 133232, upload-time = "2025-09-10T11:46:08.21Z" }, + { url = "https://files.pythonhosted.org/packages/98/c7/b6684511acc5e026650e98e029b34fa801750d29654172a1d651f619d348/zlib_ng-1.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:c6e16cb8cb140bc3e76f95294f91939929a0a3fcc0fbb6ba4191fc24dc15dea9", size = 93520, upload-time = "2025-09-10T11:54:57.879Z" }, + { url = "https://files.pythonhosted.org/packages/29/87/70b3c49c0468505cf333a9027c03b2c70f169dc6c0f4cc4d0a4ddbe38875/zlib_ng-1.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:79b172c6046d8be48500e95e3b6858056a8dfeb95c57d0403c6e7e874bcb87d9", size = 108682, upload-time = "2025-09-10T11:45:19.009Z" }, + { url = "https://files.pythonhosted.org/packages/e1/eb/293e0f4b1598a82972cb45aa80c0b2cac88f6b0f7877081e77aba1abe668/zlib_ng-1.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8da943c739ffc86679979dcb654294e6bf7d40829de7dca43d453b46b251435c", size = 91290, upload-time = "2025-09-10T11:43:56.252Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a93b686a3f2dc3c0a44a193757e8ca852f34fac64939f6bbe0c65928f7a6/zlib_ng-1.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377dd5ee851e8fea0f81811866eb0463d3e7c781d4c5fd89401ef69036befce3", size = 111303, upload-time = "2025-09-10T12:21:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/15/90ef47172106a3c56697907c048bffc14529c09c8785716ba296d27f0e4e/zlib_ng-1.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a8baaa2c766c6ae60417612ce2d8cd08555596662d6b4b5c594095dffaed5", size = 132395, upload-time = "2025-09-10T11:46:09.462Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/fe005fda8cee96c6ea4a4070d7ebbabf91f65930a750f2af4529ff36db85/zlib_ng-1.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fef21e3c5528e008ac4fc7932d373ba9854090830731db9051c2a9344ae26579", size = 112122, upload-time = "2025-09-10T12:21:27.38Z" }, + { url = "https://files.pythonhosted.org/packages/ba/2d/61b61146fcb8ccd529a0e73818c8a7f6ecdd5fb0a2c4c3be32c9a9397845/zlib_ng-1.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c30a1c8394d9c48fd9c5290355d00b6fd06f661b3c454d1747c62269e917cdd", size = 133219, upload-time = "2025-09-10T11:46:10.656Z" }, + { url = "https://files.pythonhosted.org/packages/96/cc/255bf0e3098ff31690fa4ab73606330abd9e2f8f260999938456dd450fed/zlib_ng-1.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:6ecf6ab9b7cb31ae192f469d7f1bcc1cae8314c7baf78bb174d43eb9a6e73f0d", size = 93525, upload-time = "2025-09-10T11:54:58.731Z" }, + { url = "https://files.pythonhosted.org/packages/74/ae/6626c0226806459bddd3fa1afef366455c114ce930c390ea435841bcb6ac/zlib_ng-1.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:616348ca549ba1ee286ab0c276af91f846fca07b602edc21ecf3ba6d36211a4b", size = 108617, upload-time = "2025-09-10T11:45:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/4f/95/0fe707bca0050a49997be6b562271eea63beab100520a9a40ca6e00eafa5/zlib_ng-1.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f6ef47f702374a2d0fbba709bf85cd124f3e83002ca4d51ecff55ad385ee2e44", size = 91322, upload-time = "2025-09-10T11:43:57.072Z" }, + { url = "https://files.pythonhosted.org/packages/81/32/05bbab262a70101ac6280b3b89b0a7c77df9e7bba7b7e239496d70982d12/zlib_ng-1.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501bc6fb57063e107e767ab6079cb8db98d6bacd48f4e04cb3f2ff887604e87d", size = 111366, upload-time = "2025-09-10T12:21:28.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a3/781e00b573866bbfca7edb4284495962a0e0ccd55965ac9ff7fde8aed382/zlib_ng-1.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0610467509e477b5813c0182bdcffa78b0509c03291f3a83cd844959add609b9", size = 132402, upload-time = "2025-09-10T11:46:11.494Z" }, + { url = "https://files.pythonhosted.org/packages/1d/89/7dfc3cb2a541a98ef5102f9895733527021f64af906d6c44ca260db241b7/zlib_ng-1.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a68ed1ac533c60fa9edcca857a8ef394cc340d442d79a50256a2fd8646458f20", size = 112152, upload-time = "2025-09-10T12:21:29.842Z" }, + { url = "https://files.pythonhosted.org/packages/99/2c/8d99b00e1a3425f059617eb2f242e7edfa1e5e7c50c4d9d4a99896529579/zlib_ng-1.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:034c0693a4e88b71866044e386184dedaef5e258fadb756c080fde5c609bcde1", size = 133230, upload-time = "2025-09-10T11:46:12.354Z" }, + { url = "https://files.pythonhosted.org/packages/93/4d/3475605c16a32d7ac4efc8c49c7d7b863ced4311dceca987b2f288f8d673/zlib_ng-1.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:a499413d424fd16c8a245e9dd09206f5574ec93be383a22616fb31d7be82ab75", size = 95906, upload-time = "2025-09-10T11:54:59.844Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/2eaa187c51f1aa2ae180d1252522fcb3899e0c456b01927b39965b8a84df/zlib_ng-1.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f903cb4d076ced4628284a76e5aed7b2a9e61a3c1fbe9416feaed1239d6b36ef", size = 109754, upload-time = "2025-09-10T11:45:21.423Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ec/5d97d9e979ea08793c00261e37c1c47400d066ca70f80bfb3493381e5b38/zlib_ng-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0175e33a1faf96f184cfa4c0aa542ce4146acca02f4f3420ce50e0541c926d80", size = 92534, upload-time = "2025-09-10T11:43:57.892Z" }, + { url = "https://files.pythonhosted.org/packages/51/df/83fc566a7f8140427fc812e065b89680f1ff97d60e95184553d609bfb679/zlib_ng-1.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b7d4aa8a2f165582eb2345817b4ae2fb3a90d87e9eabe2d2f1d16a14c3c14d6", size = 112130, upload-time = "2025-09-10T12:21:30.981Z" }, + { url = "https://files.pythonhosted.org/packages/d2/15/1fc7d95fda3788f6429a9067647a71d41a31f246d0012e615530959082ce/zlib_ng-1.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0da75a236bbc05b2adfd83c42bd768fbcbf665e9423e5f893f79cf7b1fcf35da", size = 132835, upload-time = "2025-09-10T11:46:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/e8bba2ee85ea99ad9a736c66d78471bb141ecb3c9ee49cfbabf0abe16f51/zlib_ng-1.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:538fbc57f29d8a1508346813e7c349286a12155de61bad862169261c3237b996", size = 112812, upload-time = "2025-09-10T12:21:32.397Z" }, + { url = "https://files.pythonhosted.org/packages/b8/16/8304e87fa66030f5f5def10fb55c1a7441c3605ce099a2ec7b5d61bded47/zlib_ng-1.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:67990ae37dca082e190487aa1af58452c474dcf137b39df736c23e91f7b0915b", size = 133554, upload-time = "2025-09-10T11:46:14.508Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f3/09d4abcea093749eeba4f7c876cf769ebf34e70df3e3041385943ca07292/zlib_ng-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:76b3832ce6b1b04ccd1efb58d4f37fabbb83eb946ea2710c19d586a9d9a4a45b", size = 97279, upload-time = "2025-09-10T11:55:01.227Z" }, +]