diff --git a/docker/Dockerfile.baseimage b/docker/Dockerfile.baseimage index 4e0fa8c2cf..8115ad7b44 100644 --- a/docker/Dockerfile.baseimage +++ b/docker/Dockerfile.baseimage @@ -74,11 +74,6 @@ COPY --chown=palace:palace packages/palace-opds/pyproject.toml /var/www/circulat RUN uv sync --frozen --no-dev --no-install-workspace && \ SIMPLIFIED_ENVIRONMENT=/var/www/circulation/environment.sh && \ echo "if [ -f $SIMPLIFIED_ENVIRONMENT ]; then source $SIMPLIFIED_ENVIRONMENT; fi" >> env/bin/activate && \ - . env/bin/activate && \ - python3 -m textblob.download_corpora lite && \ - mv /root/nltk_data /usr/lib/ && \ - find /usr/lib/nltk_data -name *.zip -delete && \ - rm -Rf /root/.cache && \ rm pyproject.toml && \ rm uv.lock && \ rm -rf packages && \ diff --git a/pyproject.toml b/pyproject.toml index e073cf262e..24e62d4c48 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,6 @@ dependencies = [ "simpleeval>=1.0.3,<2", "sqlalchemy[mypy]>=1.4.54,<2", "tenacity>=9.0.0,<10", - "textblob==0.20.0", "types-pyopenssl>=24.0.0.20240130,<25", "types-pyyaml>=6.0.12.20260518,<7", "unicodecsv==0.14.1", # this is used, but can probably be removed on py3 @@ -354,7 +353,6 @@ module = [ "palace.manager._version", "pyld", "simpleeval", - "textblob.*", "unicodecsv", "uwsgi", "wcag_contrast_ratio", diff --git a/src/palace/manager/sqlalchemy/model/identifier.py b/src/palace/manager/sqlalchemy/model/identifier.py index 762377088b..91ec72e4f0 100644 --- a/src/palace/manager/sqlalchemy/model/identifier.py +++ b/src/palace/manager/sqlalchemy/model/identifier.py @@ -1121,8 +1121,8 @@ def evaluate_summary_quality( to form an overall quality score. We need to evaluate summaries from a set of Identifiers (typically those associated with a single work) because we - need to see which noun phrases are most frequently used to - describe the underlying work. + need to see which content words are most frequently used to + describe the underlying work (its consensus vocabulary). :param privileged_data_sources: If present, a summary from one of these data source will be instantly chosen, short-circuiting the decision process. Data sources are in order of priority. diff --git a/src/palace/manager/util/summary.py b/src/palace/manager/util/summary.py index ac53c83497..c6de808f22 100644 --- a/src/palace/manager/util/summary.py +++ b/src/palace/manager/util/summary.py @@ -1,22 +1,19 @@ -import logging import re from collections import Counter from re import Pattern -from textblob import TextBlob -from textblob.exceptions import MissingCorpusError - from palace.manager.util import Bigrams, english_bigrams +from palace.manager.util.stopwords import ENGLISH_STOPWORDS class SummaryEvaluator: """Evaluate summaries of a book to find a usable summary. - A usable summary will have good coverage of the popular noun - phrases found across all summaries of the book, will have an - approximate length of four sentences (this is customizable), and - will not mention words that indicate it's a summary of a specific - edition of the book. + A usable summary will have good coverage of the words used across all + summaries of the book, weighted toward the words that recur most (a proxy + for being on-topic rather than generic marketing copy), will have an + approximate length of four sentences (this is customizable), and will not + mention words that indicate it's a summary of a specific edition of the book. All else being equal, a shorter summary is better. @@ -54,54 +51,98 @@ class SummaryEvaluator: re.compile("This is"), } - _nltk_installed = True - log = logging.getLogger("Summary Evaluator") + # Splits text into candidate word tokens. A token must start with a letter + # and may contain apostrophes thereafter (so contractions like "don't" + # survive while numbers, punctuation, and apostrophe-only runs like "'''" + # are dropped). + _word_re = re.compile(r"[a-z][a-z']*") + + # Matches a run of sentence-terminating punctuation followed by whitespace + # or the end of the string. Used as a lightweight sentence counter in place + # of a full NLP tokenizer. + _sentence_end_re = re.compile(r"[.!?]+(?=\s|$)") + + # "Shorter is better" is only a tie-breaker, so we express it as a bounded + # nudge. The nudge cannot exceed this value. + length_nudge: float = 0.05 + # Characters at which the nudge reaches half of ``length_nudge``. + length_half_life: int = 1000 def __init__( self, optimal_number_of_sentences: int = 4, - noun_phrases_to_consider: int = 10, bad_phrases: set[str] | None = None, ) -> None: self.optimal_number_of_sentences = optimal_number_of_sentences self.summaries: list[str] = [] - self.noun_phrases: Counter[str] = Counter() - self.blobs: dict[str, TextBlob] = {} - self.scores: dict[str, float] = {} - self.noun_phrases_to_consider = float(noun_phrases_to_consider) - self.top_noun_phrases: set[str] | None = None + # Maps each summary to its set of content words. + self.word_sets: dict[str, set[str]] = {} + # Counts, for each content word, the number of summaries it appears in. + self.content_words: Counter[str] = Counter() + # The sum of every content word's weight, i.e. the maximum coverage score + # a summary could earn. ``None`` until ``ready()`` computes it. + self.total_word_weight: float | None = None if bad_phrases is None: self.bad_phrases = self.default_bad_phrases else: self.bad_phrases = bad_phrases - def add(self, summary: str | bytes, parser: type[TextBlob] | None = None) -> None: - parser_class = parser or TextBlob + @staticmethod + def _word_weight(document_frequency: int) -> int: + """Weight a content word by how many summaries it appears in. + + The weight is the square of the document frequency, so words shared + across many summaries dominate the score while incidental words still + contribute a little. + """ + return document_frequency * document_frequency + + @staticmethod + def _content_words(text: str) -> set[str]: + """Return the set of meaningful (non-stopword) words in ``text``. + + We use a set rather than a list so that a single summary repeating a word + cannot inflate that word's importance -- each word counts once per summary. + """ + return { + word + for word in SummaryEvaluator._word_re.findall(text.lower()) + if len(word) > 2 and word not in ENGLISH_STOPWORDS + } + + @staticmethod + def _count_sentences(text: str) -> int: + """Approximate the number of sentences in ``text``. + + This is a heuristic (it can be fooled by abbreviations like "Mr."). + """ + return max(1, len(SummaryEvaluator._sentence_end_re.findall(text.strip()))) + + def add(self, summary: str | bytes) -> None: if isinstance(summary, bytes): summary = summary.decode("utf8") - if summary in self.blobs: + if summary in self.word_sets: # We already evaluated this summary. Don't count it more than once return - blob = parser_class(summary) - self.blobs[summary] = blob + words = self._content_words(summary) + self.word_sets[summary] = words self.summaries.append(summary) - - if self._nltk_installed: - try: - for phrase in blob.noun_phrases: - self.noun_phrases[phrase] = self.noun_phrases[phrase] + 1 - except MissingCorpusError as e: - self._nltk_installed = False - self.log.error("Summary cannot be evaluated: NLTK not installed %r" % e) + for word in words: + self.content_words[word] += 1 def ready(self) -> None: - """We are done adding to the corpus and ready to start evaluating.""" - self.top_noun_phrases = { - k - for k, v in self.noun_phrases.most_common( - int(self.noun_phrases_to_consider) - ) - } + """We are done adding to the corpus and ready to start evaluating. + + The words that characterize a work are the ones that recur across its + summaries, so each content word is weighted by its recurrence (see + :meth:`_word_weight`). We precompute the total available weight here so + that :meth:`score` can express each summary's coverage as a fraction of + it. This is what lets an on-topic summary outrank generic marketing copy, + which shares little vocabulary with the work's other descriptions. + """ + self.total_word_weight = sum( + self._word_weight(count) for count in self.content_words.values() + ) def best_choice(self) -> tuple[str, float] | tuple[None, None]: c = self.best_choices(1) @@ -119,31 +160,27 @@ def best_choices(self, n: int = 3) -> list[tuple[str, float]]: def score(self, summary: str | bytes, apply_language_penalty: bool = True) -> float: """Score a summary relative to our current view of the dataset.""" - if not self._nltk_installed: - # Without NLTK, there's no need to evaluate the score. - return 1 - if isinstance(summary, bytes): summary = summary.decode("utf8") - if summary in self.scores: - return self.scores[summary] - score = 1.0 - blob = self.blobs[summary] - if self.top_noun_phrases is None: + if self.total_word_weight is None: self.ready() - top_noun_phrases = self.top_noun_phrases or set() - top_noun_phrases_used = len( - [p for p in top_noun_phrases if p in blob.noun_phrases] - ) - score = 1 * (top_noun_phrases_used / self.noun_phrases_to_consider) - - try: - sentences = len(blob.sentences) - except Exception as e: - # Can't parse into sentences for whatever reason. - # Make a really bad guess. - sentences = summary.count(". ") + 1 + if self.total_word_weight: + words = self.word_sets.get(summary) + if words is None: + words = self._content_words(summary) + # Coverage: the share of the corpus's total (recurrence-weighted) + # vocabulary that this summary accounts for. A word the summary does + # not contain, or that never appeared in the corpus, contributes 0. + covered = sum(self._word_weight(self.content_words[w]) for w in words) + score = covered / self.total_word_weight + else: + # No summary contained any content words, so there is nothing to + # measure against. Start from a neutral score and let the penalties + # below -- length, bad phrases, and non-English text -- decide. + score = 1.0 + + sentences = self._count_sentences(summary) off_from_optimal: int | float = abs( sentences - self.optimal_number_of_sentences ) @@ -175,4 +212,8 @@ def score(self, summary: str | bytes, apply_language_penalty: bool = True) -> fl if language_difference > 1: score *= 0.5 ** (language_difference - 1) + # All else being equal, prefer a shorter summary. + saturation = len(summary) / (len(summary) + self.length_half_life) + score *= 1 - self.length_nudge * saturation + return score diff --git a/tests/manager/core/test_summary_evaluator.py b/tests/manager/core/test_summary_evaluator.py index 797dce9a86..f23e5bbfa6 100644 --- a/tests/manager/core/test_summary_evaluator.py +++ b/tests/manager/core/test_summary_evaluator.py @@ -1,8 +1,5 @@ """Test the code that evaluates the quality of summaries.""" -from textblob import TextBlob -from textblob.exceptions import MissingCorpusError - from palace.manager.util.summary import SummaryEvaluator @@ -25,18 +22,170 @@ def test_four_sentences_is_better_than_five(self): assert s2 == self._best(s1, s2) def test_shorter_is_better(self): - s1 = "A very long sentence." - s2 = "Tiny sentence." - assert s2 == self._best(s1, s2) + # The two summaries have identical word coverage, sentence count, and + # language profile, so only their length differs -- the shorter one is + # preferred. + s1 = "The White Rabbit and the Mock Turtle meet Alice." + s2 = ( + "The White Rabbit and the Mock Turtle meet Alice, " + "the White Rabbit and the Mock Turtle." + ) + assert s1 == self._best(s1, s2) - def test_noun_phrase_coverage_is_important(self): + def test_word_coverage_is_important(self): + # All three summaries are a single sentence, but s3 mentions more of + # the words that recur across the summaries, so it is preferred. s1 = "The story of Alice and the White Rabbit." s2 = "The story of Alice and the Mock Turtle." s3 = "Alice meets the Mock Turtle and the White Rabbit." - # s3 is longer, and they're all one sentence, but s3 mentions - # three noun phrases instead of two. assert s3 == self._best(s1, s2, s3) + def test_generic_marketing_copy_loses_to_on_topic_summary(self): + # The generic blurb shares no vocabulary with the other descriptions, so + # it covers none of the recurring words and is not chosen. + generic = "A sweeping tale of romance and loss. A must-read." + on_topic = ( + "Elizabeth Bennet clashes with the proud Mr. Darcy in a comedy of " + "manners about marriage." + ) + second = "Elizabeth Bennet and Mr. Darcy overcome pride to find love." + assert generic != self._best(generic, on_topic, second) + + def test_length_tiebreaker_does_not_override_word_coverage(self): + # A short summary that covers only some of the consensus vocabulary must + # not beat a longer one that covers all of it: the length preference is + # only a tie-breaker, not a signal strong enough to invert coverage. + short_partial = "Alice meets the Rabbit." + full_coverage = ( + "Alice follows the White Rabbit down the hole, meets the Mock " + "Turtle, and the Cheshire Cat, before waking from the dream." * 3 + ) + # A third summary so the recurring words become consensus vocabulary. + second = ( + "Alice, the White Rabbit, the Mock Turtle, and the Cheshire Cat " + "appear in the dream." + ) + assert full_coverage == self._best(short_partial, full_coverage, second) + + def test_length_nudge_is_bounded(self): + # The length preference is a bounded nudge, not an unbounded penalty: + # however long a summary is, the length term scales its score by at most + # ``length_nudge`` (i.e. never below ``1 - length_nudge``). + huge = "One cat. Two cats. Three cats. Four cats." + " cat" * 20000 + evaluator = SummaryEvaluator() + evaluator.add(huge) + evaluator.ready() + assert len(huge) > 50000 # a genuinely enormous summary... + score = evaluator.score(huge, apply_language_penalty=False) + assert 1 - evaluator.length_nudge <= score < 1.0 # ...yet still bounded + + def test_two_candidates_still_rank_by_vocabulary(self): + # A work with exactly two descriptions must still get a meaningful + # coverage signal. + generic = "A sweeping tale of love and loss." + on_topic = ( + "Elizabeth Bennet clashes with proud Darcy about marriage and manners." + ) + assert on_topic == self._best(generic, on_topic) + + def test_summary_with_no_content_words_is_neutral(self): + # If nothing in the corpus has content words (only stopwords/short + # tokens), there is nothing to measure coverage against, so scoring + # starts from a neutral 1.0 rather than dividing by zero. + evaluator = SummaryEvaluator() + evaluator.add("It is on us to go by.") # all stopwords / <= 2 chars + evaluator.ready() + assert evaluator.total_word_weight == 0 + assert evaluator.score("It is on us to go by.") > 0 + + def test_bytes_summaries_are_decoded(self): + # Summaries may arrive as bytes; they should be decoded and ranked just + # like str summaries. + s1 = b"Sentence one. Sentence two. Sentence three. Sentence four." + s2 = b"Only one sentence." + assert s1.decode("utf8") == self._best(s1, s2) + + def test_duplicate_summaries_are_only_counted_once(self): + summary = "Alice and the White Rabbit and the Mock Turtle." + evaluator = SummaryEvaluator() + evaluator.add(summary) + evaluator.add(summary) + assert evaluator.summaries == [summary] + + def test_scoring_an_unseen_summary(self): + # score() may be called with a summary that was never add()ed; its + # content words are computed on the fly rather than looked up. + evaluator = SummaryEvaluator() + evaluator.add("Alice meets the White Rabbit and the Mock Turtle.") + evaluator.add("The White Rabbit leads Alice to the Mock Turtle.") + evaluator.ready() + unseen = "Alice, the White Rabbit, and the Mock Turtle." + assert evaluator.score(unseen) > 0 + + def test_bad_phrases_are_penalized(self): + # A summary containing a bad phrase is scored below an otherwise + # equivalent summary without one. + clean = "Alice meets the White Rabbit and the Mock Turtle." + abridged = "An abridged version of Alice and the White Rabbit." + evaluator = SummaryEvaluator() + evaluator.add(clean) + evaluator.add(abridged) + evaluator.ready() + assert evaluator.score(clean) > evaluator.score(abridged) + + def test_best_choice_with_no_summaries(self): + evaluator = SummaryEvaluator() + evaluator.ready() + assert evaluator.best_choice() == (None, None) + + def test_custom_bad_phrases_replace_the_defaults(self): + # Two evaluators with the same corpus but different bad-phrase sets. By + # scoring the same summaries under both, every scoring term except the + # bad-phrase penalty is held constant, so a score difference isolates + # the effect of the bad-phrase set. + custom_bad = "A forbidden summary of Alice and the White Rabbit." + default_bad = "An abridged summary of Alice and the Mock Turtle." + corpus = [custom_bad, default_bad] + + default_evaluator = SummaryEvaluator() + custom_evaluator = SummaryEvaluator(bad_phrases={"forbidden"}) + for evaluator in (default_evaluator, custom_evaluator): + for summary in corpus: + evaluator.add(summary) + evaluator.ready() + + # "abridged" is a default bad phrase but not in the custom set; the + # custom evaluator therefore stops penalizing it. + assert custom_evaluator.score(default_bad) > default_evaluator.score( + default_bad + ) + # "forbidden" is only in the custom set; only the custom evaluator + # penalizes it. + assert custom_evaluator.score(custom_bad) < default_evaluator.score(custom_bad) + + def test_score_calls_ready_and_accepts_bytes(self): + # score() called before ready() triggers ready() itself, and it accepts + # bytes just like add(). + evaluator = SummaryEvaluator() + evaluator.add("Alice meets the White Rabbit and the Mock Turtle.") + assert evaluator.total_word_weight is None + score = evaluator.score(b"Alice and the White Rabbit.") + assert evaluator.total_word_weight is not None + assert score > 0 + + def test_regex_bad_patterns_and_dashes_are_penalized(self): + # The regex-based bad patterns and an excess of " -- " separators both + # push a summary's score down. + clean = "Alice meets the White Rabbit and the Mock Turtle." + bad_regex = "Includes Alice and the White Rabbit and the Mock Turtle." + dashes = "Alice -- the White -- Rabbit -- Mock -- Turtle -- again." + evaluator = SummaryEvaluator() + for summary in (clean, bad_regex, dashes): + evaluator.add(summary) + evaluator.ready() + assert evaluator.score(clean) > evaluator.score(bad_regex) + assert evaluator.score(clean) > evaluator.score(dashes) + def test_non_english_is_penalized(self): """If description text appears not to be in English, it is rated down for its deviations from average English bigram distribution. @@ -48,9 +197,10 @@ def test_non_english_is_penalized(self): evaluator.ready() dutch_no_language_penalty = evaluator.score(dutch, apply_language_penalty=False) - dutch_language_penalty = evaluator.score(dutch, apply_language_penalty=True) + assert dutch_language_penalty < dutch_no_language_penalty + def test_english_is_not_penalized(self): """If description text appears to be in English, it is not rated down for its deviations from average English bigram distribution. @@ -65,21 +215,5 @@ def test_english_is_not_penalized(self): english_no_language_penalty = evaluator.score( english, apply_language_penalty=False ) - english_language_penalty = evaluator.score(english, apply_language_penalty=True) assert english_language_penalty == english_no_language_penalty - - def test_missing_corpus_error_ignored(self): - class AlwaysErrorBlob(TextBlob): - @property - def noun_phrases(self): - raise MissingCorpusError() - - evaluator = SummaryEvaluator() - assert evaluator._nltk_installed == True - - summary = "Yes, this is a summary." - evaluator.add(summary, parser=AlwaysErrorBlob) - evaluator.add("And another", parser=AlwaysErrorBlob) - assert evaluator._nltk_installed == False - assert 1 == evaluator.score(summary) diff --git a/tox.ini b/tox.ini index 0061854642..c2a304059d 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,6 @@ envlist = py{312,313,314}-docker [testenv] runner = uv-venv-lock-runner -commands_pre = - python -m textblob.download_corpora commands = !os219-!os35: pytest {posargs:tests} # Temporary bandaid: the os219/os35 factors run only the OpenSearch-marked diff --git a/uv.lock b/uv.lock index 217a973340..6f061068b0 100644 --- a/uv.lock +++ b/uv.lock @@ -1396,15 +1396,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - [[package]] name = "joserfc" version = "1.7.3" @@ -1978,21 +1969,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/f5/ab2d6806cdf59d2c42a6e838fcdbfcfb27e1d095d44649de8f499852dae3/nameparser-1.2.1-py3-none-any.whl", hash = "sha256:0e81087e3cf6b9c88f26733a315ca21f3da05847e4863432da492927fef4dc53", size = 25726, upload-time = "2026-06-19T23:05:42.305Z" }, ] -[[package]] -name = "nltk" -version = "3.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "joblib" }, - { name = "regex" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, -] - [[package]] name = "nodeenv" version = "1.10.0" @@ -2114,7 +2090,6 @@ dependencies = [ { name = "simpleeval" }, { name = "sqlalchemy", extra = ["mypy"] }, { name = "tenacity" }, - { name = "textblob" }, { name = "types-pyopenssl" }, { name = "types-pyyaml" }, { name = "unicodecsv" }, @@ -2220,7 +2195,6 @@ requires-dist = [ { name = "simpleeval", specifier = ">=1.0.3,<2" }, { name = "sqlalchemy", extras = ["mypy"], specifier = ">=1.4.54,<2" }, { name = "tenacity", specifier = ">=9.0.0,<10" }, - { name = "textblob", specifier = "==0.20.0" }, { name = "types-pyopenssl", specifier = ">=24.0.0.20240130,<25" }, { name = "types-pyyaml", specifier = ">=6.0.12.20260518,<7" }, { name = "unicodecsv", specifier = "==0.14.1" }, @@ -3153,94 +3127,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] -[[package]] -name = "regex" -version = "2026.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, - { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, - { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, - { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, - { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, - { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, - { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, - { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, - { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, - { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, - { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, - { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, - { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, - { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, - { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, - { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, - { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, - { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, - { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, - { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, - { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, - { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, - { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, - { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, - { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, - { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, - { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, - { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, - { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, - { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, - { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, - { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, - { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, - { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, - { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, - { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, - { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, -] - [[package]] name = "requests" version = "2.34.2" @@ -3446,18 +3332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] -[[package]] -name = "textblob" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nltk" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/c0/b7f426679211e965f8bf6b65e5eafeda51842502a6ae989b9616895d5fb5/textblob-0.20.0.tar.gz", hash = "sha256:ae9bef3a16cecb4aae3e995cc8c6fe98a9d2a6ffdd58990dcd68e817e8d66b9e", size = 639377, upload-time = "2026-04-01T13:50:20.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/6e/d487f271c30700354f55b5e56339f38942ab3cb9b6f1464288e252ae3fef/textblob-0.20.0-py3-none-any.whl", hash = "sha256:f32e5240a17a98a8d026cdf7add8f5e0c7d4f1f2e91dad77bc9493e5ab3c6bd5", size = 624962, upload-time = "2026-04-01T13:50:19.423Z" }, -] - [[package]] name = "tomli-w" version = "1.2.0" @@ -3538,18 +3412,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/4a33dc81da39db7b31e5622333df361e8fe055b7ec636bd5fea762c9182d/tox_uv_bare-1.35.2-py3-none-any.whl", hash = "sha256:c0d590a41d1054a1ad0874e9e5943ff52402786e3d4599d8f8d37a65b566ef53", size = 22307, upload-time = "2026-05-05T01:34:17.681Z" }, ] -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - [[package]] name = "types-aws-xray-sdk" version = "2.15.0.20260518"