From 2e72eac1a2229fc3409cd19f38afad46195fd442 Mon Sep 17 00:00:00 2001 From: mdevolde Date: Sun, 17 May 2026 17:26:00 +0200 Subject: [PATCH] refactor: harden ruff rules --- .pre-commit-config.yaml | 4 +- CHANGELOG.md | 4 +- Makefile | 8 +- README.md | 6 +- language_tool_python/__init__.py | 4 +- language_tool_python/__main__.py | 261 ++++++---- language_tool_python/_deprecated.py | 29 +- language_tool_python/api_types.py | 157 ++++++ language_tool_python/config_file.py | 224 ++++---- language_tool_python/download_lt.py | 506 ++++++++++-------- language_tool_python/exceptions.py | 55 +- language_tool_python/language_tag.py | 58 ++- language_tool_python/match.py | 330 ++++++------ language_tool_python/safe_zip.py | 287 +++++++---- language_tool_python/server.py | 746 +++++++++++++++------------ language_tool_python/utils.py | 217 +++++--- make.bat | 8 +- pyproject.toml | 94 +++- tests/test_api_public.py | 58 ++- tests/test_cli.py | 54 +- tests/test_config.py | 87 ++-- tests/test_deprecated.py | 21 +- tests/test_download.py | 229 ++++---- tests/test_match.py | 115 +++-- tests/test_safe_zip.py | 180 +++---- tests/test_server_local.py | 120 ++--- uv.lock | 496 +++++++++--------- 27 files changed, 2521 insertions(+), 1837 deletions(-) create mode 100644 language_tool_python/api_types.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0baa1d7..4f3464f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: # Ruff for linting and formatting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.12 # Keep in sync ruff version with pyproject.toml + rev: v0.15.16 # Keep in sync ruff version with pyproject.toml hooks: # Run the linter - id: ruff @@ -11,7 +11,7 @@ repos: # mypy for type checking - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.0.0 # Keep in sync mypy version with pyproject.toml + rev: v2.1.0 # Keep in sync mypy version with pyproject.toml hooks: - id: mypy args: [--config-file=pyproject.toml] diff --git a/CHANGELOG.md b/CHANGELOG.md index cb8ca2c..3ecd5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,6 @@ - `download_folder` in `utils.find_existing_language_tool_downloads` from `str` to `Path` - Edited return types of some methods/functions: - from `str` to `Path` in `utils.get_language_tool_download_path` - - from `List[str]` to `List[Path]` in `utils.find_existing_language_tool_downloads` + - from `list[str]` to `list[Path]` in `utils.find_existing_language_tool_downloads` - from `str` to `Path` in `utils.get_language_tool_directory` - - from `Tuple[str, str]` to `Tuple[Path, Path]` in `utils.get_jar_info` + - from `tuple[str, str]` to `tuple[Path, Path]` in `utils.get_jar_info` diff --git a/Makefile b/Makefile index 5cadd2e..2798ebd 100644 --- a/Makefile +++ b/Makefile @@ -13,14 +13,14 @@ install: uv sync --all-groups --locked format: - uv run --group quality --locked ruff format language_tool_python tests + uv run --group quality --locked ruff format fix: - uv run --group quality --locked ruff check --fix language_tool_python tests + uv run --group quality --locked ruff check --fix ruff-check: - uv run --group quality --locked ruff check language_tool_python tests - uv run --group quality --locked ruff format --check language_tool_python tests + uv run --group quality --locked ruff check + uv run --group quality --locked ruff format --check mypy-check: @if uv run --locked python -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then \ diff --git a/README.md b/README.md index 97732cd..6a5d853 100644 --- a/README.md +++ b/README.md @@ -308,9 +308,11 @@ Exit codes: - `LTP_JAR_DIR_PATH`: use an existing local LanguageTool directory (skip download). - `LTP_DOWNLOAD_HOST_SNAPSHOT`: override snapshot download host. - default: `https://internal1.languagetool.org/snapshots/` -- `LTP_DOWNLOAD_HOST_RELEASE`: override release download host. +- `LTP_DOWNLOAD_HOST_NEW_RELEASES`: override release download host for LanguageTool `>= 6.7`. + - default: `https://github.com/jxmorris12/language_tool_python/releases/download/LanguageTool-{version}/` +- `LTP_DOWNLOAD_HOST_RELEASE`: override release download host for LanguageTool `6.0` to `6.6`. - default: `https://languagetool.org/download/` -- `LTP_DOWNLOAD_HOST_ARCHIVE`: override archive download host. +- `LTP_DOWNLOAD_HOST_ARCHIVE`: override archive download host for LanguageTool `4.0` to `5.9`. - default: `https://languagetool.org/download/archive/` - `LTP_DOWNLOAD_SHA256_`: version-specific expected SHA-256 for the downloaded LanguageTool archive, for example `LTP_DOWNLOAD_SHA256_6_9_SNAPSHOT`. - `LTP_DOWNLOAD_SHA256`: fallback expected SHA-256 for the downloaded LanguageTool archive. diff --git a/language_tool_python/__init__.py b/language_tool_python/__init__.py index baef772..34d992b 100644 --- a/language_tool_python/__init__.py +++ b/language_tool_python/__init__.py @@ -1,12 +1,12 @@ """LanguageTool API for Python.""" __all__ = [ + "LanguageTag", "LanguageTool", "LanguageToolPublicAPI", - "LanguageTag", "Match", - "utils", "exceptions", + "utils", ] import logging diff --git a/language_tool_python/__main__.py b/language_tool_python/__main__.py index b4cf361..09b068a 100644 --- a/language_tool_python/__main__.py +++ b/language_tool_python/__main__.py @@ -1,45 +1,51 @@ """LanguageTool command line.""" +from __future__ import annotations + import argparse import importlib.resources -import locale import logging import re import sys import traceback +from collections.abc import Sequence from importlib.metadata import PackageNotFoundError, version from logging.config import dictConfig from pathlib import Path -from typing import Any, Optional, Sequence, Set, Union +from typing import TYPE_CHECKING, cast import toml from .exceptions import LanguageToolError from .server import LanguageTool +if TYPE_CHECKING: + from collections.abc import Sequence + try: __version__ = version("language_tool_python") -except PackageNotFoundError: # If the package is not installed in the environment, read the version from pyproject.toml + # If the package is not installed in the environment, + # read the version from pyproject.toml +except PackageNotFoundError: project_root = Path(__file__).resolve().parent.parent pyproject = project_root / "pyproject.toml" - with open(pyproject, "rb") as f: + with pyproject.open("rb") as f: __version__ = toml.loads(f.read().decode("utf-8"))["project"]["version"] logger = logging.getLogger(__name__) with ( importlib.resources.as_file( - importlib.resources.files("language_tool_python").joinpath("logging.toml") + importlib.resources.files("language_tool_python").joinpath("logging.toml"), ) as config_path, - open(config_path, "rb") as f, + config_path.open("rb") as f, ): log_config = toml.loads(f.read().decode("utf-8")) dictConfig(log_config) -def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: - """ - Parse command line arguments. +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse command line arguments. :return: parsed arguments :rtype: argparse.Namespace @@ -134,11 +140,11 @@ def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: class RulesAction(argparse.Action): - """ - Custom argparse action to update a set of rules in the namespace. - This action is used to modify the set of rules stored in the argparse - namespace when the action is triggered. It updates the attribute specified - by 'self.dest' with the provided values. + """Custom argparse action to update a set of rules in the namespace. + + This action is used to modify the set of rules stored in the argparse namespace when + the action is triggered. It updates the attribute specified by 'self.dest' with the + provided values. """ dest: str @@ -146,67 +152,66 @@ class RulesAction(argparse.Action): def __call__( self, - parser: argparse.ArgumentParser, - namespace: Any, - values: Any, - option_string: Optional[str] = None, + _parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[object] | None, + _option_string: str | None = None, ) -> None: - """ - This method is called when the action is triggered. It updates the set of rules - in the namespace with the provided values. The method is invoked automatically - by argparse when the corresponding command-line argument is encountered. + """Update the namespace rule set when the action is triggered. - :param parser: The ArgumentParser object which contains this action. - :type parser: argparse.ArgumentParser + The method updates the set of rules in the namespace with the provided values. + It is invoked automatically by argparse when the corresponding command-line + argument is encountered. + + :param _parser: The ArgumentParser object which contains this action. + :type _parser: argparse.ArgumentParser :param namespace: The namespace object that will be returned by parse_args(). - :type namespace: Any + :type namespace: argparse.Namespace :param values: The argument values associated with the action. - :type values: Any - :param option_string: The option string that was used to invoke this action. - :type option_string: Optional[str] + :type values: str | Sequence[object] | None + :param _option_string: The option string that was used to invoke this action. + :type _option_string: str | None """ - getattr(namespace, self.dest).update(values) + getattr(namespace, self.dest).update( + cast("set[str]", values), + ) -def get_rules(rules: str) -> Set[str]: - """ - Parse a string of rules and return a set of rule IDs. +def get_rules(rules: str) -> set[str]: + """Parse a string of rules and return a set of rule IDs. :param rules: A string containing rule IDs separated by non-word characters. :type rules: str :return: A set of rule IDs. - :rtype: Set[str] + :rtype: set[str] """ return {rule.upper() for rule in re.findall(r"[\w\-]+", rules)} def get_text( - filename: Union[str, int], - encoding: Optional[str], - ignore: Optional[str], + filename: str | int, + encoding: str | None, + ignore: str | None, ) -> str: - """ - Read the content of a file and return it as a string, optionally ignoring lines that match a regular expression. + """Read a file and optionally ignore lines matching a regex. :param filename: The name of the file to read or file descriptor. - :type filename: Union[str, int] + :type filename: str | int :param encoding: The encoding to use for reading the file. - :type encoding: Optional[str] + :type encoding: str | None :param ignore: A regular expression pattern to match lines that should be ignored. - :type ignore: Optional[str] + :type ignore: str | None :return: The content of the file as a string. :rtype: str """ - with open(filename, encoding=encoding) as f: + with open(filename, encoding=encoding) as f: # noqa: PTH123 # Need to use classic open() here to support file descriptors return "".join( - "\n" if (ignore and re.match(ignore, line)) else line - for line in f.readlines() + "\n" if (ignore and re.match(ignore, line)) else line for line in f ) def print_exception(exc: Exception, debug: bool) -> None: - """ - Print an exception message to stderr, optionally including a stack trace. + """Print an exception message to stderr, optionally including a stack trace. :param exc: The exception to print. :type exc: Exception @@ -219,55 +224,78 @@ def print_exception(exc: Exception, debug: bool) -> None: print(exc, file=sys.stderr) -def main(argv: Optional[Sequence[str]] = None) -> int: - """ - Main function to parse arguments, process files, and check text using LanguageTool. +def get_remote_server(args: argparse.Namespace) -> str | None: + """Build the remote server address from parsed arguments. - :return: Exit status code - :rtype: int + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + :return: The remote server address in the format "host:port" or None if no remote + host is specified. + :rtype: str | None """ - args = parse_args(argv) + if args.remote_host is None: + return None - if args.verbose: - logging.getLogger().setLevel(logging.DEBUG) + remote_server: str = args.remote_host + if args.remote_port is not None: + remote_server += f":{args.remote_port}" - status = 0 + return remote_server - for filename in args.files: - if len(args.files) > 1: - print(filename, file=sys.stderr) - - remote_server = None - if args.remote_host is not None: - remote_server = args.remote_host - if args.remote_port is not None: - remote_server += f":{args.remote_port}" + +def get_input_text(filename: str, args: argparse.Namespace) -> str: + """Read input text from a file or stdin. + + :param filename: The name of the file to read or "-" for stdin. + :type filename: str + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + :return: The input text as a string. + :rtype: str + """ + if filename == "-": + raw = sys.stdin.read() + if args.ignore_lines: + return "".join( + "\n" if re.match(args.ignore_lines, line) else line + for line in raw.splitlines(keepends=True) + ) + return raw + + encoding = args.encoding or "utf-8" + return get_text(filename, encoding, ignore=args.ignore_lines) + + +def process_file( + filename: str, + args: argparse.Namespace, + remote_server: str | None, +) -> int: + """Check a single input file and return the resulting status. + + :param filename: The name of the file to check or "-" for stdin. + :type filename: str + :param args: Parsed command-line arguments. + :type args: argparse.Namespace + :param remote_server: The remote server address or None. + :type remote_server: str | None + :return: The resulting status. + :rtype: int + """ + if len(args.files) > 1: + print(filename, file=sys.stderr) + + try: with LanguageTool( language=args.language, mother_tongue=args.mother_tongue, remote_server=remote_server, ) as lang_tool: - if filename == "-": - encoding = args.encoding or ( - sys.stdin.encoding - if sys.stdin.isatty() - else locale.getpreferredencoding() - ) - raw = sys.stdin.read() - if args.ignore_lines: - text = "".join( - "\n" if re.match(args.ignore_lines, line) else line - for line in raw.splitlines(keepends=True) - ) - else: - text = raw - else: - encoding = args.encoding or "utf-8" - try: - text = get_text(filename, encoding, ignore=args.ignore_lines) - except (UnicodeError, FileNotFoundError) as exception: - print_exception(exception, args.verbose) - continue + try: + text = get_input_text(filename, args) + except (UnicodeError, FileNotFoundError) as exception: + print_exception(exception, args.verbose) + return 0 if not args.spell_check: lang_tool.disable_spellchecking() @@ -279,32 +307,55 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if args.picky: lang_tool.picky = True - try: - if args.apply: - print(lang_tool.correct(text)) - else: - for match in lang_tool.check(text): - rule_id = match.rule_id + if args.apply: + print(lang_tool.correct(text)) + return 0 - replacement_text = ", ".join( - f"'{word}'" for word in match.replacements - ).strip() + status = 0 + for match in lang_tool.check(text): + rule_id = match.rule_id - message = match.message + replacement_text = ", ".join( + f"'{word}'" for word in match.replacements + ).strip() - # Messages that end with punctuation already include the - # suggestion. - if replacement_text and not message.endswith("?"): - message += " Suggestions: " + replacement_text + message = match.message - line, column = match.get_line_and_column(text) + # Messages that end with punctuation already include the + # suggestion. + if replacement_text and not message.endswith("?"): + message += " Suggestions: " + replacement_text - print(f"{filename}:{line}:{column}: {rule_id}: {message}") + line, column = match.get_line_and_column(text) - status = 2 - except LanguageToolError as exception: - print_exception(exception, args.verbose) - continue + print(f"{filename}:{line}:{column}: {rule_id}: {message}") + + status = 2 + + return status + except LanguageToolError as exception: + print_exception(exception, args.verbose) + return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + """Parse arguments, process files, and check text using LanguageTool. + + :param argv: Command-line arguments to parse, or None to use sys.argv. + :type argv: Sequence[str] | None + :return: Exit status code + :rtype: int + """ + args = parse_args(argv) + + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + status = 0 + remote_server = get_remote_server(args) + + for filename in args.files: + status = max(status, process_file(filename, args, remote_server)) return status diff --git a/language_tool_python/_deprecated.py b/language_tool_python/_deprecated.py index 466542e..c63cf8c 100644 --- a/language_tool_python/_deprecated.py +++ b/language_tool_python/_deprecated.py @@ -1,37 +1,42 @@ +"""Provide a deprecated decorator for marking functions or classes as deprecated. + +It first attempts to import the deprecated decorator from the warnings module, available +in Python 3.13 and later. If the import fails (indicating an earlier Python version), it +defines a custom deprecated decorator. The decorator from warnings issues a +DeprecationWarning when the decorated object is used during runtime, and triggers static +linters to flag the usage as deprecated. The custom decorator also issues a +DeprecationWarning when the decorated object is used, but does not trigger static +linters. """ -This module provides a deprecated decorator for marking functions or classes as deprecated. -It first attempts to import the deprecated decorator from the warnings module, available in Python 3.13 and later. -If the import fails (indicating an earlier Python version), it defines a custom deprecated decorator. -The decorator from warnings issues a DeprecationWarning when the decorated object is used during runtime, -and triggers static linters to flag the usage as deprecated. -The custom decorator also issues a DeprecationWarning when the decorated object is used, but does not trigger static linters. -""" + +from __future__ import annotations try: from warnings import deprecated # type: ignore [attr-defined, unused-ignore] except ImportError: import functools - from typing import Any, Callable, Optional, Type, TypeVar, cast + from collections.abc import Callable + from typing import TypeVar, cast from warnings import warn - F = TypeVar("F", bound=Callable[..., Any]) + F = TypeVar("F", bound=Callable[..., object]) def deprecated( # type: ignore [no-redef, unused-ignore] message: str, /, *, - category: Optional[Type[Warning]] = DeprecationWarning, + category: type[Warning] | None = DeprecationWarning, stacklevel: int = 1, ) -> Callable[[F], F]: """Indicate that a function is deprecated.""" def decorator(func: F) -> F: @functools.wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: object, **kwargs: object) -> object: warn(message, category=category, stacklevel=stacklevel) return func(*args, **kwargs) - return cast(F, wrapper) + return cast("F", wrapper) return decorator diff --git a/language_tool_python/api_types.py b/language_tool_python/api_types.py new file mode 100644 index 0000000..be04427 --- /dev/null +++ b/language_tool_python/api_types.py @@ -0,0 +1,157 @@ +"""Typed representations of LanguageTool API responses.""" + +from __future__ import annotations + +from typing import TypedDict + +__all__ = [ + "Category", + "CheckMatch", + "CheckResponse", + "Context", + "DetectedLanguage", + "Language", + "LanguageInfo", + "MatchType", + "Replacement", + "Rule", + "WarningInfo", + "is_check_response", + "is_language_info", +] + + +class LanguageInfo(TypedDict): + """Language metadata returned by the LanguageTool languages endpoint.""" + + code: str + longCode: str + name: str + + +def is_language_info(value: object) -> bool: # No TypeGuard because py3.9 + """Verify that a value is a LanguageInfo. + + :param value: The value to check. + :type value: object + :return: True if the value is a LanguageInfo, False otherwise. + :rtype: bool + """ + if not isinstance(value, dict): + return False + + return ( + isinstance(value.get("code"), str) + and isinstance(value.get("longCode"), str) + and isinstance(value.get("name"), str) + ) + + +class _ReplacementOptional(TypedDict, total=False): + shortDescription: str + + +class Replacement(_ReplacementOptional): + """A suggested replacement returned by LanguageTool.""" + + value: str + + +class Context(TypedDict): + """Text context around a LanguageTool match.""" + + text: str + offset: int + length: int + + +class Category(TypedDict): + """LanguageTool rule category metadata.""" + + id: str + name: str + + +class _RuleOptional(TypedDict, total=False): + sourceFile: str + subId: str + + +class Rule(_RuleOptional): + """LanguageTool rule metadata for a match.""" + + id: str + description: str + issueType: str + category: Category + + +class MatchType(TypedDict): + """LanguageTool match type metadata.""" + + typeName: str + + +class CheckMatch(TypedDict): + """A raw match object returned by the LanguageTool check endpoint.""" + + message: str + shortMessage: str + replacements: list[Replacement] + offset: int + length: int + context: Context + sentence: str + type: MatchType + rule: Rule + ignoreForIncompleteSentence: bool + contextForSureMatch: int + + +class DetectedLanguage(TypedDict): + """Detected language metadata returned by LanguageTool.""" + + code: str + confidence: float + name: str + source: str + + +class Language(TypedDict): + """Language metadata returned by the LanguageTool check endpoint.""" + + code: str + name: str + detectedLanguage: DetectedLanguage + + +class WarningInfo(TypedDict): + """Warning flags returned by LanguageTool.""" + + incompleteResults: bool + + +class CheckResponse(TypedDict): + """Raw JSON response returned by the LanguageTool check endpoint.""" + + matches: list[CheckMatch] + language: Language + warnings: WarningInfo + + +def is_check_response(value: object) -> bool: # No TypeGuard because py3.9 + """Verify that a value is a CheckResponse. + + :param value: The value to check. + :type value: object + :return: True if the value is a CheckResponse, False otherwise. + :rtype: bool + """ + if not isinstance(value, dict): + return False + + return ( + isinstance(value.get("matches"), list) + and isinstance(value.get("language"), dict) + and isinstance(value.get("warnings"), dict) + ) diff --git a/language_tool_python/config_file.py b/language_tool_python/config_file.py index 997353b..68afd8e 100644 --- a/language_tool_python/config_file.py +++ b/language_tool_python/config_file.py @@ -1,26 +1,40 @@ """Module for configuring LanguageTool's local server.""" +from __future__ import annotations + import atexit import logging import tempfile +from collections.abc import Iterable, Mapping from dataclasses import dataclass +from os import PathLike from pathlib import Path -from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Union +from typing import Callable, Generic, TypeVar, Union, cast from .exceptions import PathError +from .utils import SupportsBool + +# Union here because | not supported by PathLike in py3.9 +ConfigValue = Union[PathLike[str], SupportsBool, str, int, float, Iterable[str]] + +ConfigValueT_contra = TypeVar("ConfigValueT_contra", contravariant=True) logger = logging.getLogger(__name__) +LANGUAGE_KEY_PARTS = 2 +LANGUAGE_KEY_WITH_DICT_PATH_PARTS = 3 +LANGUAGE_DICT_PATH_SEPARATOR_COUNT = 2 + def _reject_line_breaks(field_name: str, value: str) -> None: - """ - Reject values that would break the one-option-per-line config format. + """Reject values that would break the one-option-per-line config format. :param field_name: The name of the configuration field being validated. :type field_name: str :param value: The value of the configuration field to validate. :type value: str - :raises ValueError: If the value contains line break characters or ends with an odd number of backslashes. + :raises ValueError: If the value contains line break characters or ends with an odd + number of backslashes. """ if "\n" in value or "\r" in value: err = f"config {field_name} cannot contain line breaks" @@ -33,53 +47,59 @@ def _reject_line_breaks(field_name: str, value: str) -> None: @dataclass(frozen=True) -class OptionSpec: - """ - Specification for a configuration option. +class OptionSpec(Generic[ConfigValueT_contra]): + """Specification for a configuration option. - This class defines the structure and behavior of a configuration option, - including its type constraints, encoding mechanism, and optional validation. + This class defines the structure and behavior of a configuration option, including + its type constraints, encoding mechanism, and optional validation. - This class is frozen (immutable) to ensure configuration specifications - remain constant throughout the application lifecycle. + This class is frozen (immutable) to ensure configuration specifications remain + constant throughout the application lifecycle. """ - py_types: Union[type, Tuple[type, ...]] + py_types: type | tuple[type, ...] """The Python type(s) that this option accepts.""" - encoder: Callable[[Any], str] + encoder: Callable[[ConfigValueT_contra], str] """A callable that converts the option value to its string representation.""" - validator: Optional[Callable[[Any], None]] = None + validator: Callable[[ConfigValueT_contra], None] | None = None """An optional validator function for the option value.""" -def _bool_encoder(v: Any) -> str: - """ - Encode a value as a lowercase boolean string. +def _bool_encoder(v: SupportsBool) -> str: + """Encode a value as a lowercase boolean string. - Converts any value to a boolean and returns its string representation - in lowercase format ('true' or 'false'). + Converts any value to a boolean and returns its string representation in lowercase + format ('true' or 'false'). :param v: The value to be converted to a boolean string. - :type v: Any + :type v: SupportsBool :return: A lowercase string representation of the boolean value ('true' or 'false'). :rtype: str """ return str(bool(v)).lower() -def _comma_list_encoder(v: Union[str, Iterable[str]]) -> str: - """ - Encode a value as a comma-separated list string. +def _int_encoder(v: int) -> str: + """Encode an integer value as a string.""" + return str(int(v)) + + +def _number_encoder(v: int | float) -> str: + """Encode a numeric value as a string.""" + return str(float(v)) + + +def _comma_list_encoder(v: str | Iterable[str]) -> str: + """Encode a value as a comma-separated list string. - Converts a value into a string representation suitable for comma-separated - list configuration options. If the input is already a string, it is returned - as-is. If it's an iterable, its elements are converted to strings and joined - with commas. + Converts a value into a string representation suitable for comma-separated list + configuration options. If the input is already a string, it is returned as-is. If + it's an iterable, its elements are converted to strings and joined with commas. :param v: The value to encode. Can be a string or an iterable of values. - :type v: Union[str, Iterable[str]] + :type v: str | Iterable[str] :return: A comma-separated string representation of the input value. :rtype: str """ @@ -88,29 +108,27 @@ def _comma_list_encoder(v: Union[str, Iterable[str]]) -> str: return ",".join(str(x) for x in v) -def _path_encoder(v: Any) -> str: - """ - Encode a path value to a string. - Converts the input to a Path object, then to a string, and escapes all - backslashes by doubling them. This is useful for windows file paths and - other contexts where backslashes need to be escaped. (because they will - be used by LT java binary) - - :param v: The path value to encode. Can be any type that Path accepts - (str, Path, etc.). - :type v: Any - :return: The path as a string with escaped backslashes (e.g., "C:\\\\Users\\\\file"). +def _path_encoder(v: PathLike[str] | str) -> str: + r"""Encode a path value to a string. + + Converts the input to a Path object, then to a string, and escapes all backslashes + by doubling them. This is useful for windows file paths and other contexts where + backslashes need to be escaped. (because they will be used by LT java binary) + + :param v: The path value to encode. Can be any type that Path accepts (str, Path, + etc.). + :type v: PathLike[str] | str + :return: The path as a string with escaped backslashes (e.g., "C:\\Users\\file"). :rtype: str """ return str(Path(v)).replace("\\", "\\\\") -def _path_validator(v: Any) -> None: - """ - Validate that a given path exists and is a file. +def _path_validator(v: PathLike[str] | str) -> None: + """Validate that a given path exists and is a file. :param v: The path to validate, which will be converted to a Path object - :type v: Any + :type v: PathLike[str] | str :raises PathError: If the path does not exist :raises PathError: If the path exists but is not a file """ @@ -123,39 +141,42 @@ def _path_validator(v: Any) -> None: raise PathError(err) -CONFIG_SCHEMA: Dict[str, OptionSpec] = { - "maxTextLength": OptionSpec(int, lambda v: str(int(v))), - "maxTextHardLength": OptionSpec(int, lambda v: str(int(v))), - "maxCheckTimeMillis": OptionSpec(int, lambda v: str(int(v))), - "maxErrorsPerWordRate": OptionSpec((int, float), lambda v: str(float(v))), - "maxSpellingSuggestions": OptionSpec(int, lambda v: str(int(v))), - "maxCheckThreads": OptionSpec(int, lambda v: str(int(v))), - "cacheSize": OptionSpec(int, lambda v: str(int(v))), - "cacheTTLSeconds": OptionSpec(int, lambda v: str(int(v))), - "requestLimit": OptionSpec(int, lambda v: str(int(v))), - "requestLimitInBytes": OptionSpec(int, lambda v: str(int(v))), - "timeoutRequestLimit": OptionSpec(int, lambda v: str(int(v))), - "requestLimitPeriodInSeconds": OptionSpec(int, lambda v: str(int(v))), - "languageModel": OptionSpec((str, Path), _path_encoder, _path_validator), - "fasttextModel": OptionSpec((str, Path), _path_encoder, _path_validator), - "fasttextBinary": OptionSpec((str, Path), _path_encoder, _path_validator), - "maxWorkQueueSize": OptionSpec(int, lambda v: str(int(v))), - "rulesFile": OptionSpec((str, Path), _path_encoder, _path_validator), - "blockedReferrers": OptionSpec((str, list, tuple, set), _comma_list_encoder), - "premiumOnly": OptionSpec((bool, int), _bool_encoder), - "disabledRuleIds": OptionSpec((str, list, tuple, set), _comma_list_encoder), - "pipelineCaching": OptionSpec((bool, int), _bool_encoder), - "maxPipelinePoolSize": OptionSpec(int, lambda v: str(int(v))), - "pipelineExpireTimeInSeconds": OptionSpec(int, lambda v: str(int(v))), - "pipelinePrewarming": OptionSpec((bool, int), _bool_encoder), - "trustXForwardForHeader": OptionSpec((bool, int), _bool_encoder), - "suggestionsEnabled": OptionSpec((bool, int), _bool_encoder), -} +CONFIG_SCHEMA = cast( + "dict[str, OptionSpec[ConfigValue]]", + { + "maxTextLength": OptionSpec(int, _int_encoder), + "maxTextHardLength": OptionSpec(int, _int_encoder), + "maxCheckTimeMillis": OptionSpec(int, _int_encoder), + "maxErrorsPerWordRate": OptionSpec((int, float), _number_encoder), + "maxSpellingSuggestions": OptionSpec(int, _int_encoder), + "maxCheckThreads": OptionSpec(int, _int_encoder), + "cacheSize": OptionSpec(int, _int_encoder), + "cacheTTLSeconds": OptionSpec(int, _int_encoder), + "requestLimit": OptionSpec(int, _int_encoder), + "requestLimitInBytes": OptionSpec(int, _int_encoder), + "timeoutRequestLimit": OptionSpec(int, _int_encoder), + "requestLimitPeriodInSeconds": OptionSpec(int, _int_encoder), + "languageModel": OptionSpec((str, Path), _path_encoder, _path_validator), + "fasttextModel": OptionSpec((str, Path), _path_encoder, _path_validator), + "fasttextBinary": OptionSpec((str, Path), _path_encoder, _path_validator), + "maxWorkQueueSize": OptionSpec(int, _int_encoder), + "rulesFile": OptionSpec((str, Path), _path_encoder, _path_validator), + "blockedReferrers": OptionSpec((str, list, tuple, set), _comma_list_encoder), + "premiumOnly": OptionSpec((bool, int), _bool_encoder), + "disabledRuleIds": OptionSpec((str, list, tuple, set), _comma_list_encoder), + "pipelineCaching": OptionSpec((bool, int), _bool_encoder), + "maxPipelinePoolSize": OptionSpec(int, _int_encoder), + "pipelineExpireTimeInSeconds": OptionSpec(int, _int_encoder), + "pipelinePrewarming": OptionSpec((bool, int), _bool_encoder), + "trustXForwardForHeader": OptionSpec((bool, int), _bool_encoder), + "suggestionsEnabled": OptionSpec((bool, int), _bool_encoder), + }, +) def _is_lang_key(key: str) -> bool: - """ - Check if a given key is a valid language key. + """Check if a given key is a valid language key. + A valid language key must follow one of these formats: - lang- where code is a non-empty language code @@ -170,31 +191,33 @@ def _is_lang_key(key: str) -> bool: return False parts = key.split("-") - return (len(parts) == 2 and len(parts[1]) > 0) or ( # lang- - len(parts) == 3 + return (len(parts) == LANGUAGE_KEY_PARTS and len(parts[1]) > 0) or ( # lang- + len(parts) == LANGUAGE_KEY_WITH_DICT_PATH_PARTS and len(parts[1]) > 0 and parts[2] == "dictPath" # lang--dictPath ) -def _encode_config(config: Dict[str, Any]) -> Dict[str, str]: - """ - Encode configuration dictionary values to their string representations. +def _encode_config(config: Mapping[str, ConfigValue]) -> dict[str, str]: + """Encode configuration dictionary values to their string representations. + This function converts a configuration dictionary into a format suitable for serialization by encoding each value according to its corresponding schema specification. :param config: A dictionary containing configuration keys and values to be encoded. - :type config: Dict[str, Any] + :type config: Mapping[str, ConfigValue] :return: A dictionary with the same keys but with all values encoded as strings. - :rtype: Dict[str, str] - :raises ValueError: If a key in the config is not found in the CONFIG_SCHEMA and - is not a language key. - :raises TypeError: If a value's type does not match the expected type(s) defined - in the CONFIG_SCHEMA specification. + :rtype: dict[str, str] + :raises ValueError: If a key in the config is not found in the CONFIG_SCHEMA and is + not a language key, or if a key/value cannot be serialized safely. + :raises TypeError: If a value's type does not match the expected type(s) defined in + the CONFIG_SCHEMA specification. + :raises PathError: If a path-like configuration value does not point to an existing + file. """ logger.debug("Encoding LanguageTool config with keys: %s", list(config.keys())) - encoded: Dict[str, str] = {} + encoded: dict[str, str] = {} for key, value in config.items(): _reject_line_breaks("key", key) if _is_lang_key(key) and key.count("-") == 1: # lang- @@ -202,10 +225,13 @@ def _encode_config(config: Dict[str, Any]) -> Dict[str, str]: encoded[key] = str(value) _reject_line_breaks(key, encoded[key]) continue - if _is_lang_key(key) and key.count("-") == 2: # lang--dictPath + if ( + _is_lang_key(key) and key.count("-") == LANGUAGE_DICT_PATH_SEPARATOR_COUNT + ): # lang--dictPath logger.debug("Encoding language dictPath %s=%r", key, value) - _path_validator(value) - encoded[key] = _path_encoder(value) + path_value = cast("PathLike[str] | str", value) + _path_validator(path_value) + encoded[key] = _path_encoder(path_value) _reject_line_breaks(key, encoded[key]) continue @@ -225,22 +251,25 @@ def _encode_config(config: Dict[str, Any]) -> Dict[str, str]: class LanguageToolConfig: - """ - Configuration class for LanguageTool. + """Configuration class for LanguageTool. :param config: Dictionary containing configuration keys and values. - :type config: Dict[str, Any] + :type config: Mapping[str, ConfigValue] """ - config: Dict[str, Any] + config: dict[str, str] """Dictionary containing configuration keys and values.""" path: str """Path to the temporary file storing the configuration.""" - def __init__(self, config: Dict[str, Any]): - """ - Initialize the LanguageToolConfig object. + def __init__(self, config: Mapping[str, ConfigValue]) -> None: + """Initialize the LanguageToolConfig object. + + :raises ValueError: If the config is empty or contains invalid keys/values. + :raises TypeError: If a config value has an unsupported type. + :raises PathError: If a path-like config value does not point to an existing + file. """ if not config: err = "config cannot be empty" @@ -250,8 +279,7 @@ def __init__(self, config: Dict[str, Any]): self.path = self._create_temp_file() def _create_temp_file(self) -> str: - """ - Create a temporary file to store the configuration. + """Create a temporary file to store the configuration. :return: Path to the temporary file. :rtype: str diff --git a/language_tool_python/download_lt.py b/language_tool_python/download_lt.py index 513530d..6a9a19a 100755 --- a/language_tool_python/download_lt.py +++ b/language_tool_python/download_lt.py @@ -1,5 +1,7 @@ """LanguageTool download module.""" +from __future__ import annotations + import contextlib import hashlib import importlib.resources @@ -10,11 +12,11 @@ import tempfile import zipfile from abc import ABC, abstractmethod -from datetime import datetime +from datetime import datetime, timezone from functools import total_ordering from pathlib import Path from shutil import which -from typing import IO, Dict, List, Optional, Tuple, Union +from typing import IO, TYPE_CHECKING from urllib.parse import urljoin from warnings import warn @@ -25,7 +27,6 @@ from packaging.version import Version from ._deprecated import deprecated -from .config_file import LanguageToolConfig from .exceptions import JavaError, PathError from .safe_zip import SafeZipExtractor from .utils import ( @@ -34,8 +35,19 @@ get_language_tool_download_path, ) +if TYPE_CHECKING: + from types import NotImplementedType + + from .config_file import LanguageToolConfig + logger = logging.getLogger(__name__) +MIN_JAVA_VERSION_FOR_OLD_LANGUAGE_TOOL = 9 +MIN_JAVA_VERSION_FOR_CURRENT_LANGUAGE_TOOL = 17 +HTTP_STATUS_NOT_FOUND = 404 +HTTP_STATUS_FORBIDDEN = 403 +HTTP_STATUS_OK = 200 + # Get download host from environment or default. BASE_URL_SNAPSHOT = os.environ.get( @@ -67,9 +79,9 @@ with ( importlib.resources.as_file( - importlib.resources.files("language_tool_python").joinpath("integrity.toml") + importlib.resources.files("language_tool_python").joinpath("integrity.toml"), ) as hashes_path, - open(hashes_path, "rb") as f, + hashes_path.open("rb") as f, ): EXPECTED_DOWNLOAD_SHA256 = toml.loads(f.read().decode("utf-8")) @@ -91,19 +103,28 @@ ) # 512 MiB, latest snapshot: 246.58 MiB archive -def _get_zip_hash(version_name: str) -> Optional[str]: +def _get_zip_hash(version_name: str) -> str | None: """Get the expected SHA-256 hash for a given version of LanguageTool. - This function checks for environment variables that may specify the expected hash for the given version. It normalizes the version name to construct the environment variable name. If no specific environment variable is found for the version, it falls back to a general environment variable or a manifest lookup. If the bypass environment variable is set, it will skip verification and return None. - :param version_name: The version name of LanguageTool (e.g., '6.0', '20240101', or 'latest'). + This function checks for environment variables that may specify the expected hash + for the given version. It normalizes the version name to construct the environment + variable name. If no specific environment variable is found for the version, it + falls back to a general environment variable or a manifest lookup. If the bypass + environment variable is set, it will skip verification and return None. + + :param version_name: The version name of LanguageTool (e.g., '6.0', '20240101', or + 'latest'). :type version_name: str - :return: The expected SHA-256 hash for the given version, or None if verification is bypassed or no hash is configured. - :rtype: Optional[str] + :return: The expected SHA-256 hash for the given version, or None if verification is + bypassed or no hash is configured. + :rtype: str | None + :raises PathError: If a configured checksum is not a valid SHA-256 value. """ if os.environ.get(LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, "").lower() == "true": err = ( f"Verified downloads are bypassed. No SHA-256 checksum will be used for " - f"LanguageTool {version_name}. Set {LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR}=false to re-enable verification." + f"LanguageTool {version_name}. Set {LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR}=" + f"false to re-enable verification." ) warn(err, RuntimeWarning, stacklevel=2) return None @@ -124,14 +145,13 @@ def _get_zip_hash(version_name: str) -> Optional[str]: return None -def _validate_download_size(content_length: Optional[str]) -> Optional[int]: - """ - Validate the HTTP Content-Length header before downloading a ZIP file. +def _validate_download_size(content_length: str | None) -> int | None: + """Validate the HTTP Content-Length header before downloading a ZIP file. :param content_length: The Content-Length header value, if present. - :type content_length: Optional[str] + :type content_length: str | None :return: The parsed content length, or None when the header is missing. - :rtype: Optional[int] + :rtype: int | None :raises PathError: If the header is invalid or exceeds the download size limit. """ if content_length is None: @@ -157,18 +177,17 @@ def _validate_download_size(content_length: Optional[str]) -> Optional[int]: return total -def parse_java_version(version_text: str) -> Tuple[int, int]: - """ - Parse the Java version from a given version text. +def parse_java_version(version_text: str) -> tuple[int, int]: + """Parse the Java version from a given version text. - This function attempts to extract the major version numbers from the provided - Java version string using regular expressions. It supports two different - version formats defined by JAVA_VERSION_REGEX and JAVA_VERSION_REGEX_UPDATED. + This function attempts to extract the major version numbers from the provided Java + version string using regular expressions. It supports two different version formats + defined by JAVA_VERSION_REGEX and JAVA_VERSION_REGEX_UPDATED. :param version_text: The Java version string to parse. :type version_text: str :return: A tuple containing the major version numbers. - :rtype: Tuple[int, int] + :rtype: tuple[int, int] :raises SystemExit: If the version string cannot be parsed. """ match = re.search(JAVA_VERSION_REGEX, version_text) or re.search( @@ -186,17 +205,18 @@ def parse_java_version(version_text: str) -> Tuple[int, int]: def confirm_java_compatibility( language_tool_version: str = LTP_DOWNLOAD_VERSION, ) -> None: - """ - Confirms if the installed Java version is compatible with language-tool-python. - This function checks if Java is installed and verifies that the major version is at least 9 or 17 (depending on the LanguageTool version). - It raises an error if Java is not installed or if the version is incompatible. + """Confirm that the installed Java version is compatible with language-tool-python. + + This function checks if Java is installed and verifies that the major version is at + least 9 or 17 (depending on the LanguageTool version). It raises an error if Java is + not installed or if the version is incompatible. - :param language_tool_version: The version of LanguageTool to check compatibility for. + :param language_tool_version: The version of LanguageTool to check compatibility + for. :type language_tool_version: str :raises ModuleNotFoundError: If no Java installation is detected. :raises SystemError: If the detected Java version is less than the required version. """ - java_path = which("java") if not java_path: err = ( @@ -224,38 +244,50 @@ def confirm_java_compatibility( # Some installs of java show the version number like '14.0.1' # and others show '1.14.0.1' # (with a leading 1). We want to support both. - # (See softwareengineering.stackexchange.com/questions/175075/why-is-java-version-1-x-referred-to-as-java-x) + # (See https://softwareengineering.stackexchange.com/questions/175075/why-is-java-version-1-x-referred-to-as-java-x) if is_old_version: - if (major_version == 1 and minor_version < 9) or ( - major_version != 1 and major_version < 9 - ): - err = f"Detected java {major_version}.{minor_version}. LanguageTool requires Java >= 9 for version {language_tool_version}." - raise SystemError(err) - else: - if (major_version == 1 and minor_version < 17) or ( - major_version != 1 and major_version < 17 + if ( + major_version == 1 + and minor_version < MIN_JAVA_VERSION_FOR_OLD_LANGUAGE_TOOL + ) or ( + major_version != 1 + and major_version < MIN_JAVA_VERSION_FOR_OLD_LANGUAGE_TOOL ): - err = f"Detected java {major_version}.{minor_version}. LanguageTool requires Java >= 17 for version {language_tool_version}." + err = ( + f"Detected java {major_version}.{minor_version}. LanguageTool " + f"requires Java >= 9 for version {language_tool_version}." + ) raise SystemError(err) + elif ( + major_version == 1 + and minor_version < MIN_JAVA_VERSION_FOR_CURRENT_LANGUAGE_TOOL + ) or ( + major_version != 1 + and major_version < MIN_JAVA_VERSION_FOR_CURRENT_LANGUAGE_TOOL + ): + err = ( + f"Detected java {major_version}.{minor_version}. LanguageTool " + f"requiresJava >= 17 for version {language_tool_version}." + ) + raise SystemError(err) @deprecated( "This function is no longer used internally and will be removed in 4.0.", stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] -def get_common_prefix(z: zipfile.ZipFile) -> Optional[str]: - """ - Determine the common prefix of all file names in a zip archive. +def get_common_prefix(z: zipfile.ZipFile) -> str | None: + """Determine the common prefix of all file names in a zip archive. :param z: A ZipFile object representing the zip archive. :type z: zipfile.ZipFile - :return: The common prefix of all file names in the zip archive, or None if there is no common prefix. - :rtype: Optional[str] + :return: The common prefix of all file names in the zip archive, or None if there + is no common prefix. + :rtype: str | None .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. """ - name_list = z.namelist() if name_list and all(n.startswith(name_list[0]) for n in name_list[1:]): return name_list[0] @@ -269,11 +301,14 @@ def get_common_prefix(z: zipfile.ZipFile) -> Optional[str]: def http_get( url: str, out_file: IO[bytes], - proxies: Optional[Dict[str, str]] = None, + proxies: dict[str, str] | None = None, ) -> None: - """ - .. deprecated:: 3.3.0 - This function is no longer used internally and will be removed in 4.0. + """.. deprecated:: 3.3.0. + + This function is no longer used internally and will be removed in 4.0. + + :raises TimeoutError: If the download request times out. + :raises PathError: If the download fails or checksum validation fails. """ version_match = re.search(r"LanguageTool-(.+)\.zip", url) version_name = version_match.group(1) if version_match else LTP_DOWNLOAD_VERSION @@ -287,7 +322,7 @@ def http_get( # Fallback to default behavior if the extracted version is not supported local_lt = LocalLanguageTool.from_version_name(LTP_DOWNLOAD_VERSION) - with local_lt._get_remote_zip(out_file, proxies=proxies): + with local_lt._get_remote_zip(out_file, proxies=proxies): # noqa: SLF001 # Accessing protected member temporarily for backward compatibility with the deprecated http_get function pass @@ -296,18 +331,18 @@ def http_get( stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] def unzip_file(temp_file_name: str, directory_to_extract_to: Path) -> None: - """ - Unzips a zip file to a specified directory. + """Unzips a zip file to a specified directory. - :param temp_file_name: A temporary file object representing the zip file to be extracted. + :param temp_file_name: Path to the zip file to be extracted. :type temp_file_name: str - :param directory_to_extract_to: The directory where the contents of the zip file will be extracted. + :param directory_to_extract_to: The directory where the contents of the zip file + will be extracted. :type directory_to_extract_to: Path + :raises PathError: If the ZIP archive or extraction destination is unsafe. .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. """ - logger.info("Unzipping %s to %s", temp_file_name, directory_to_extract_to) with ( tempfile.TemporaryDirectory(dir=directory_to_extract_to.parent) as temp_dir, @@ -325,13 +360,14 @@ def unzip_file(temp_file_name: str, directory_to_extract_to: Path) -> None: stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] def download_zip(url: str, directory: Path) -> None: - """ - Downloads a ZIP file from the given URL and extracts it to the specified directory. + """Download a ZIP file from the given URL and extract it to the specified directory. :param url: The URL of the ZIP file to download. :type url: str :param directory: The directory where the ZIP file should be extracted. :type directory: Path + :raises TimeoutError: If the download request times out. + :raises PathError: If the download fails or the ZIP extraction is unsafe. .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. @@ -348,12 +384,15 @@ def download_zip(url: str, directory: Path) -> None: @deprecated( - "This function is no longer used internally and will be removed in 4.0.\nUse instead language_tool_python.download_lt.LocalLanguageTool.download.", + ( + "This function is no longer used internally and will be removed in 4.0.\n" + "Use instead language_tool_python.download_lt.LocalLanguageTool.download." + ), stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] def download_lt(language_tool_version: str = LTP_DOWNLOAD_VERSION) -> None: - """ - Downloads and extracts the specified version of LanguageTool. + """Download and extract the specified version of LanguageTool. + This function checks for Java compatibility, and downloads the specified version of LanguageTool if it is not already present. @@ -361,8 +400,12 @@ def download_lt(language_tool_version: str = LTP_DOWNLOAD_VERSION) -> None: specified, the default version defined by LTP_DOWNLOAD_VERSION is used. :type language_tool_version: str - :raises PathError: If the download folder is not a directory. :raises ValueError: If the specified version format is invalid. + :raises ModuleNotFoundError: If no Java installation is detected. + :raises SystemError: If the detected Java version is incompatible. + :raises TimeoutError: If the download request times out. + :raises PathError: If the version is unsupported, the download fails, checksum + validation fails, or ZIP extraction is unsafe. .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. @@ -380,24 +423,23 @@ def download_lt(language_tool_version: str = LTP_DOWNLOAD_VERSION) -> None: @total_ordering class LocalLanguageTool(ABC): - """ - Abstract base class for managing local LanguageTool installations. + """Abstract base class for managing local LanguageTool installations. This class provides common functionality for handling LanguageTool downloads, - installations, and server command generation. It supports both release versions - and snapshot versions through its subclasses. + installations, and server command generation. It supports both release versions and + snapshot versions through its subclasses. """ @classmethod def from_version_name( cls, version_name: str = LTP_DOWNLOAD_VERSION, - ) -> "LocalLanguageTool": - """ - Create a LocalLanguageTool instance from a version name. + ) -> LocalLanguageTool: + """Create a LocalLanguageTool instance from a version name. - This factory method determines the appropriate subclass (ReleaseLocalLanguageTool - or SnapshotLocalLanguageTool) based on the version name format. + This factory method determines the appropriate subclass + (ReleaseLocalLanguageTool or SnapshotLocalLanguageTool) based on the version + name format. :param version_name: The version name (e.g., '6.8', '20240101', or 'latest'). :type version_name: str @@ -412,21 +454,22 @@ def from_version_name( return SnapshotLocalLanguageTool(version_name) if re.match(r"^\d+\.\d+$", version_name): return ReleaseLocalLanguageTool(version_name) - raise ValueError(f"Unknown LanguageTool version name: {version_name}") + err = f"Unknown LanguageTool version name: {version_name}" + raise ValueError(err) @classmethod - def from_path(cls, path: Path) -> "LocalLanguageTool": - """ - Create a LocalLanguageTool instance from a directory path. + def from_path(cls, path: Path) -> LocalLanguageTool: + """Create a LocalLanguageTool instance from a directory path. - This factory method extracts the version name from a LanguageTool directory - path and creates the appropriate instance. + This factory method extracts the version name from a LanguageTool directory path + and creates the appropriate instance. :param path: The path to a LanguageTool installation directory. :type path: Path :return: An instance of the appropriate LocalLanguageTool subclass. :rtype: LocalLanguageTool - :raises ValueError: If the version cannot be determined from the path. + :raises ValueError: If the version cannot be determined from the path or the + extracted version name is unsupported. """ match = re.search(r"LanguageTool-(.+)", path.name) if not match: @@ -437,50 +480,68 @@ def from_path(cls, path: Path) -> "LocalLanguageTool": @abstractmethod def download(self) -> None: - """ - Download and install the LanguageTool version. + """Download and install the LanguageTool version. + + This abstract method must be implemented by subclasses to handle version- + specific download logic. - This abstract method must be implemented by subclasses to handle - version-specific download logic. + :raises NotImplementedError: Always, unless implemented by a subclass. """ - pass + raise NotImplementedError def _get_remote_zip( - self, downloaded_file: IO[bytes], proxies: Optional[Dict[str, str]] = None + self, + downloaded_file: IO[bytes], + proxies: dict[str, str] | None = None, ) -> zipfile.ZipFile: - """ - Download a LanguageTool ZIP file from a remote URL. + """Download a LanguageTool ZIP file from a remote URL. - This method handles the HTTP request, progress tracking, and error handling - for downloading LanguageTool ZIP files. + This method handles the HTTP request, progress tracking, and error handling for + downloading LanguageTool ZIP files. :param downloaded_file: A file-like object to write the downloaded content to. :type downloaded_file: IO[bytes] :param proxies: Optional proxy configuration for the HTTP request. - :type proxies: Optional[Dict[str, str]] + :type proxies: dict[str, str] | None :return: A ZipFile object of the downloaded archive. :rtype: zipfile.ZipFile :raises TimeoutError: If the download request times out. - :raises PathError: If the download fails due to HTTP errors (404, 403, etc.) or if the checksum does not match. + :raises PathError: If the download fails due to HTTP errors (404, 403, etc.), + if the checksum configuration is invalid, if the checksum does not match, + or if the archive exceeds the configured download size limit. """ logger.info("Starting download from %s", self.download_url) expected_sha256 = _get_zip_hash(self.version_name) sha256 = hashlib.sha256() try: req = requests.get( - self.download_url, stream=True, proxies=proxies, timeout=60 + self.download_url, + stream=True, + proxies=proxies, + timeout=60, ) except requests.exceptions.Timeout as e: err = f"Request to {self.download_url} timed out." raise TimeoutError(err) from e - if req.status_code == 404: - err = f"Could not find at URL {self.download_url}. The given version may not exist or is no longer available." + if req.status_code == HTTP_STATUS_NOT_FOUND: + err = ( + f"Could not find at URL {self.download_url}. " + f"The given version may not exist or is no longer available." + ) raise PathError(err) - if req.status_code == 403: - err = f"Access forbidden to URL {self.download_url}. You may not have permission to access this resource. It may be related to network restrictions (e.g., firewall, proxy settings)." + if req.status_code == HTTP_STATUS_FORBIDDEN: + err = ( + f"Access forbidden to URL {self.download_url}. " + f"You may not have permission to access this resource. " + f"It may be related to network restrictions (e.g., firewall, " + f"proxy settings)." + ) raise PathError(err) - if req.status_code != 200: - err = f"Failed to download from {self.download_url}. HTTP status code: {req.status_code}." + if req.status_code != HTTP_STATUS_OK: + err = ( + f"Failed to download from {self.download_url}. " + f"HTTP status code: {req.status_code}." + ) raise PathError(err) content_length = req.headers.get("Content-Length") total = _validate_download_size(content_length) @@ -517,22 +578,21 @@ def _get_remote_zip( return zipfile.ZipFile(downloaded_file) @classmethod - def get_installed_versions(cls) -> List["LocalLanguageTool"]: - """ - Get a list of all installed LanguageTool versions. + def get_installed_versions(cls) -> list[LocalLanguageTool]: + """Get a list of all installed LanguageTool versions. - This method scans the download directory for LanguageTool installations - and returns a list of LocalLanguageTool instances representing each version. + This method scans the download directory for LanguageTool installations and + returns a list of LocalLanguageTool instances representing each version. :return: A list of installed LocalLanguageTool instances. - :rtype: List[LocalLanguageTool] + :rtype: list[LocalLanguageTool] """ download_folder = get_language_tool_download_path() language_tool_path_list = [ path for path in download_folder.glob("LanguageTool*") if path.is_dir() ] - versions: List["LocalLanguageTool"] = [] + versions: list[LocalLanguageTool] = [] for path in language_tool_path_list: match = re.search(r"LanguageTool-(.+)", path.name) if match: @@ -541,15 +601,15 @@ def get_installed_versions(cls) -> List["LocalLanguageTool"]: return versions @classmethod - def get_latest_installed_version(cls) -> Optional["LocalLanguageTool"]: - """ - Get the latest installed LanguageTool version. + def get_latest_installed_version(cls) -> LocalLanguageTool | None: + """Get the latest installed LanguageTool version. This method finds all installed versions and returns the most recent one according to version ordering. - :return: The latest installed LocalLanguageTool instance, or None if no versions are installed. - :rtype: Optional[LocalLanguageTool] + :return: The latest installed LocalLanguageTool instance, or None if no versions + are installed. + :rtype: LocalLanguageTool | None """ versions = cls.get_installed_versions() if not versions: @@ -557,11 +617,10 @@ def get_latest_installed_version(cls) -> Optional["LocalLanguageTool"]: return max(versions) def get_directory_path(self) -> Path: - """ - Get the installation directory path for this LanguageTool version. + """Get the installation directory path for this LanguageTool version. - This method searches the download folder for the directory matching - this version's name. + This method searches the download folder for the directory matching this + version's name. :return: The path to the LanguageTool installation directory. :rtype: Path @@ -572,7 +631,7 @@ def get_directory_path(self) -> Path: path for path in download_folder.glob("LanguageTool*") if path.is_dir() ] - if not len(language_tool_path_list): + if not language_tool_path_list: err = f"LanguageTool not found in {download_folder}." raise FileNotFoundError(err) @@ -587,8 +646,7 @@ def get_directory_path(self) -> Path: raise FileNotFoundError(err) def get_jar_path(self) -> Path: - """ - Get the path to the LanguageTool JAR file. + """Get the path to the LanguageTool JAR file. This method locates the main JAR file (languagetool-server.jar or languagetool.jar) within the installation directory. @@ -611,18 +669,22 @@ def get_jar_path(self) -> Path: def get_server_cmd( self, - port: Optional[int] = None, - config: Optional[LanguageToolConfig] = None, - ) -> List[str]: - """ - Generate the command to start the LanguageTool HTTP server. - - :param port: Optional; The port number on which the server should run. If not provided, the default port will be used. - :type port: Optional[int] - :param config: Optional; The configuration for the LanguageTool server. If not provided, default configuration will be used. - :type config: Optional[LanguageToolConfig] + port: int | None = None, + config: LanguageToolConfig | None = None, + ) -> list[str]: + """Generate the command to start the LanguageTool HTTP server. + + :param port: Optional; The port number on which the server should run. If not + provided, the default port will be used. + :type port: int | None + :param config: Optional; The configuration for the LanguageTool server. If not + provided, default configuration will be used. + :type config: LanguageToolConfig | None :return: A list of command line arguments to start the LanguageTool HTTP server. - :rtype: List[str] + :rtype: list[str] + :raises JavaError: If the Java executable cannot be found. + :raises FileNotFoundError: If the LanguageTool installation directory or JAR + file cannot be found. """ java_path_str = which("java") if not java_path_str: @@ -649,55 +711,55 @@ def get_server_cmd( @property @abstractmethod def version_name(self) -> str: - """ - Get the version name string. + """Get the version name string. - This abstract property must be implemented by subclasses to return - the version identifier. + This abstract property must be implemented by subclasses to return the version + identifier. :return: The version name. :rtype: str + :raises NotImplementedError: Always, unless implemented by a subclass. """ - pass + raise NotImplementedError @property @abstractmethod - def version_into(self) -> Union[Version, datetime]: - """ - Get the version as a comparable object. + def version_into(self) -> Version | datetime: + """Get the version as a comparable object. - This abstract property must be implemented by subclasses to return - the version as either a Version object (for releases) or datetime - object (for snapshots) for comparison purposes. + This abstract property must be implemented by subclasses to return the version + as either a Version object (for releases) or datetime object (for snapshots) for + comparison purposes. :return: A Version object for releases or datetime for snapshots. - :rtype: Union[Version, datetime] + :rtype: Version | datetime + :raises NotImplementedError: Always, unless implemented by a subclass. """ - pass + raise NotImplementedError @property @abstractmethod def download_url(self) -> str: - """ - Get the download URL for this LanguageTool version. + """Get the download URL for this LanguageTool version. - This abstract property must be implemented by subclasses to return - the appropriate download URL. + This abstract property must be implemented by subclasses to return the + appropriate download URL. :return: The download URL. :rtype: str + :raises NotImplementedError: Always, unless implemented by a subclass. """ - pass + raise NotImplementedError def __eq__(self, other: object) -> bool: - """ - Check equality between two LocalLanguageTool instances. + """Check equality between two LocalLanguageTool instances. Two instances are considered equal if they have the same version name. :param other: The object to compare with. :type other: object - :return: True if equal, False otherwise, NotImplemented for non-LocalLanguageTool objects. + :return: True if equal, False otherwise, NotImplemented for non- + LocalLanguageTool objects. :rtype: bool """ if not isinstance(other, LocalLanguageTool): @@ -705,45 +767,60 @@ def __eq__(self, other: object) -> bool: return self.version_name == other.version_name def __lt__(self, other: object) -> bool: - """ - Compare two LocalLanguageTool instances for ordering. + """Compare two LocalLanguageTool instances for ordering. - Snapshot versions are always considered newer than release versions. - Within the same type, versions are compared using their version_into property. + Snapshot versions are always considered newer than release versions. Within the + same type, versions are compared using their version_into property. :param other: The object to compare with. :type other: object - :return: True if self is less than other, False otherwise, NotImplemented for incompatible types. + :return: True if self is less than other, False otherwise, NotImplemented for + incompatible types. :rtype: bool """ - if not isinstance(other, LocalLanguageTool): - return NotImplemented - if isinstance(self, SnapshotLocalLanguageTool) and isinstance( - other, ReleaseLocalLanguageTool - ): - return False - if isinstance(self, ReleaseLocalLanguageTool) and isinstance( - other, SnapshotLocalLanguageTool - ): - return True - if type(self) != type(other): - return NotImplemented - # At this point, both objects are the same type, so version_into will be the same type - self_version = self.version_into - other_version = other.version_into - if isinstance(self_version, Version) and isinstance(other_version, Version): - return self_version < other_version - if isinstance(self_version, datetime) and isinstance(other_version, datetime): - return self_version < other_version - return NotImplemented + res: bool | NotImplementedType = NotImplemented + if isinstance(other, LocalLanguageTool): + if isinstance(self, SnapshotLocalLanguageTool) and isinstance( + other, ReleaseLocalLanguageTool + ): + res = False + elif isinstance(self, ReleaseLocalLanguageTool) and isinstance( + other, SnapshotLocalLanguageTool + ): + res = True + elif type(self) is not type(other): + res = NotImplemented + else: + self_version = self.version_into + other_version = other.version_into + if ( + isinstance(self_version, Version) + and isinstance(other_version, Version) + ) or ( + isinstance(self_version, datetime) + and isinstance(other_version, datetime) + ): + res = self_version < other_version # type: ignore[operator] # mypy doesn't get that the types are the same here + else: + res = NotImplemented + return res + + def __hash__(self) -> int: + """Return the hash of the LocalLanguageTool instance. + + If the version name is modified, the hash will change. + + :return: The hash of the version name. + :rtype: int + """ + return hash(self.version_name) class ReleaseLocalLanguageTool(LocalLanguageTool): - """ - Represents a release version of LanguageTool. + """Represents a release version of LanguageTool. - This class handles release versions of LanguageTool (e.g., '6.0', '5.9') - which are downloaded from the official release pages. + This class handles release versions of LanguageTool (e.g., '6.0', '5.9') which are + downloaded from the official release pages. Releases are the old way of downloading LanguageTool. @@ -752,17 +829,20 @@ class ReleaseLocalLanguageTool(LocalLanguageTool): """ def __init__(self, version: str) -> None: - """ - Initialize a ReleaseLocalLanguageTool instance. - """ + """Initialize a ReleaseLocalLanguageTool instance.""" self._version_name = version def download(self) -> None: - """ - Download and install this release version of LanguageTool. + """Download and install this release version of LanguageTool. + + This method checks Java compatibility, downloads the release ZIP file, and + extracts it to the download folder if not already installed. - This method checks Java compatibility, downloads the release ZIP file, - and extracts it to the download folder if not already installed. + :raises ModuleNotFoundError: If no Java installation is detected. + :raises SystemError: If the detected Java version is incompatible. + :raises TimeoutError: If the download request times out. + :raises PathError: If the version is unsupported, the download fails, checksum + validation fails, or ZIP extraction is unsafe. """ confirm_java_compatibility(self._version_name) @@ -777,7 +857,8 @@ def download(self) -> None: with ( tempfile.TemporaryDirectory(dir=download_folder) as temp_dir, tempfile.NamedTemporaryFile( - suffix=".zip", dir=temp_dir + suffix=".zip", + dir=temp_dir, ) as downloaded_file, self._get_remote_zip(downloaded_file) as zip_file, ): @@ -789,8 +870,7 @@ def download(self) -> None: @property def version_name(self) -> str: - """ - Get the release version name. + """Get the release version name. :return: The release version string. :rtype: str @@ -799,8 +879,7 @@ def version_name(self) -> str: @property def version_into(self) -> Version: - """ - Get the version as a Version object for comparison. + """Get the version as a Version object for comparison. :return: A parsed Version object from the version string. :rtype: Version @@ -809,8 +888,7 @@ def version_into(self) -> Version: @property def download_url(self) -> str: - """ - Get the download URL for this release version. + """Get the download URL for this release version. URLs are constructed based on version: - Versions >= 6.7 are downloaded from the new release page @@ -834,7 +912,8 @@ def download_url(self) -> str: if version_num < Version("4.0"): err = ( "LanguageTool versions below 4.0 are no longer supported for download." - " Below version 4.0, the API changed significantly and is not compatible." + " Below version 4.0, the API changed significantly and is " + "not compatible." ) raise PathError(err) # Versions < 6.0 from archive @@ -842,11 +921,10 @@ def download_url(self) -> str: class SnapshotLocalLanguageTool(LocalLanguageTool): - """ - Represents a snapshot (development) version of LanguageTool. + """Represents a snapshot (development) version of LanguageTool. - This class handles snapshot versions of LanguageTool, which are nightly - builds identified by date strings (e.g., '20240101') or 'latest'. + This class handles snapshot versions of LanguageTool, which are nightly builds + identified by date strings (e.g., '20240101') or 'latest'. Snapshots are the new common way of downloading LanguageTool. @@ -855,22 +933,25 @@ class SnapshotLocalLanguageTool(LocalLanguageTool): """ def __init__(self, version_name: str) -> None: - """ - Initialize a SnapshotLocalLanguageTool instance. - """ + """Initialize a SnapshotLocalLanguageTool instance.""" self._version_name = version_name self._install_version_name = ( - datetime.now().strftime("%Y%m%d") + datetime.now(timezone.utc).strftime("%Y%m%d") if version_name == LT_SNAPSHOT_LATEST_VERSION else version_name ) def download(self) -> None: - """ - Download and install this snapshot version of LanguageTool. + """Download and install this snapshot version of LanguageTool. - This method checks Java compatibility, downloads the snapshot ZIP file, - and extracts it to the download folder using the requested snapshot name. + This method checks Java compatibility, downloads the snapshot ZIP file, and + extracts it to the download folder using the requested snapshot name. + + :raises ModuleNotFoundError: If no Java installation is detected. + :raises SystemError: If the detected Java version is incompatible. + :raises TimeoutError: If the download request times out. + :raises PathError: If the download fails, checksum validation fails, ZIP + extraction is unsafe, or the extracted snapshot layout is invalid. """ confirm_java_compatibility(self._version_name) @@ -885,7 +966,8 @@ def download(self) -> None: with ( tempfile.TemporaryDirectory(dir=download_folder) as temp_dir, tempfile.NamedTemporaryFile( - suffix=".zip", dir=temp_dir + suffix=".zip", + dir=temp_dir, ) as downloaded_file, self._get_remote_zip(downloaded_file) as zip_file, ): @@ -920,8 +1002,7 @@ def download(self) -> None: @property def version_name(self) -> str: - """ - Get the snapshot version name. + """Get the snapshot version name. Returns the current date if 'latest' was specified, otherwise returns the specified date string. @@ -933,21 +1014,20 @@ def version_name(self) -> str: @property def version_into(self) -> datetime: - """ - Get the snapshot version as a datetime object for comparison. + """Get the snapshot version as a datetime object for comparison. - Converts the version date string to a datetime object. For 'latest', - uses the current date. + Converts the version date string to a datetime object. For 'latest', uses the + current date. :return: A datetime object representing the snapshot date. :rtype: datetime + :raises ValueError: If the snapshot version is not a valid ``YYYYMMDD`` date. """ - return datetime.strptime(self.version_name, "%Y%m%d") + return datetime.strptime(self.version_name, "%Y%m%d") # noqa: DTZ007 # Constructing a datetime without timezone because it is the format of the version string @property def download_url(self) -> str: - """ - Get the download URL for this snapshot version. + """Get the download URL for this snapshot version. Constructs the URL to download the snapshot from the snapshot server. diff --git a/language_tool_python/exceptions.py b/language_tool_python/exceptions.py index c5a8b69..17565b1 100644 --- a/language_tool_python/exceptions.py +++ b/language_tool_python/exceptions.py @@ -1,49 +1,42 @@ +"""Exceptions used in the language_tool_python library.""" + + class LanguageToolError(Exception): - """ - Exception raised for errors in the LanguageTool library. - This is a generic exception that can be used to indicate various types of - errors encountered while using the LanguageTool library. - """ + """Exception raised for errors in the LanguageTool library. - pass + This is a generic exception that can be used to indicate various types of errors + encountered while using the LanguageTool library. + """ class ServerError(LanguageToolError): - """ - Exception raised for errors that occur when interacting with the LanguageTool server. - This exception is a subclass of ``LanguageToolError`` and is used to indicate - issues such as server startup failures. - """ + """Raised when interacting with the LanguageTool server fails. - pass + This exception is a subclass of ``LanguageToolError`` and is used to indicate issues + such as server startup failures. + """ class JavaError(LanguageToolError): - """ - Exception raised for errors related to the Java backend of LanguageTool. - This exception is a subclass of ``LanguageToolError`` and is used to indicate - issues that occur when interacting with Java, such as Java not being found. - """ + """Exception raised for errors related to the Java backend of LanguageTool. - pass + This exception is a subclass of ``LanguageToolError`` and is used to indicate issues + that occur when interacting with Java, such as Java not being found. + """ class PathError(LanguageToolError): - """ - Exception raised for errors in the file path used in LanguageTool. - This error is raised when there is an issue with the file path provided - to LanguageTool, such as the LanguageTool JAR file not being found, - or a download path not being a valid available file path. - """ + """Exception raised for errors in the file path used in LanguageTool. - pass + This error is raised when there is an issue with the file path provided to + LanguageTool, such as the LanguageTool JAR file not being found, or a download path + not being a valid available file path. + """ class RateLimitError(LanguageToolError): - """ - Exception raised for errors related to rate limiting in the LanguageTool server. - This exception is a subclass of ``LanguageToolError`` and is used to indicate - issues such as exceeding the allowed number of requests to the public API without a key. - """ + """Exception raised for errors related to rate limiting in the LanguageTool server. - pass + This exception is a subclass of ``LanguageToolError`` and is used to indicate issues + such as exceeding the allowed number of requests to the public API without a key. + """ diff --git a/language_tool_python/language_tag.py b/language_tool_python/language_tag.py index db9546c..6130db9 100644 --- a/language_tool_python/language_tag.py +++ b/language_tool_python/language_tag.py @@ -2,21 +2,21 @@ import logging import re +from collections.abc import Iterable from functools import total_ordering -from typing import Any, Iterable logger = logging.getLogger(__name__) @total_ordering class LanguageTag: - """ - A class to represent and normalize language tags. + """A class to represent and normalize language tags. :param tag: The language tag. :type tag: str :param languages: An iterable of supported language tags. :type languages: Iterable[str] + :raises ValueError: If the tag is empty or unsupported. """ tag: str @@ -28,42 +28,46 @@ class LanguageTag: normalized_tag: str """The normalized language tag.""" - _LANGUAGE_RE = re.compile(r"^([a-z]{2,3})(?:[_-]([a-z]{2}))?$", re.I) + _LANGUAGE_RE = re.compile(r"^([a-z]{2,3})(?:[_-]([a-z]{2}))?$", re.IGNORECASE) """A regular expression to match language tags.""" def __init__(self, tag: str, languages: Iterable[str]) -> None: - """ - Initialize a LanguageTag instance. + """Initialize a LanguageTag instance. + + :raises ValueError: If the tag is empty or unsupported. """ self.tag = tag self.languages = languages self.normalized_tag = self._normalize(tag) - def __eq__(self, other: Any) -> bool: - """ - Compare this LanguageTag object with another for equality. + def __eq__(self, other: object) -> bool: + """Compare this LanguageTag object with another for equality. :param other: The other object to compare with. - :type other: Any + :type other: object :return: True if the normalized tags are equal, False otherwise. :rtype: bool + :raises ValueError: If ``other`` is a string with an unsupported language tag. """ - return self.normalized_tag == self._normalize(other) + if not isinstance(other, (str, LanguageTag)): + return NotImplemented + return self.normalized_tag == self._normalize(str(other)) - def __lt__(self, other: Any) -> bool: - """ - Compare this object with another for less-than ordering. + def __lt__(self, other: object) -> bool: + """Compare this object with another for less-than ordering. :param other: The object to compare with. - :type other: Any + :type other: object :return: True if this object is less than the other, False otherwise. :rtype: bool + :raises ValueError: If ``other`` is a string with an unsupported language tag. """ - return str(self) < self._normalize(other) + if not isinstance(other, (str, LanguageTag)): + return NotImplemented + return str(self) < self._normalize(str(other)) def __str__(self) -> str: - """ - Returns the string representation of the object. + """Return the string representation of the object. :return: The normalized tag as a string. :rtype: str @@ -71,17 +75,25 @@ def __str__(self) -> str: return self.normalized_tag def __repr__(self) -> str: - """ - Return a string representation of the LanguageTag instance. + """Return a string representation of the LanguageTag instance. :return: A string in the format '' :rtype: str """ - return f'' + return f'' - def _normalize(self, tag: str) -> str: + def __hash__(self) -> int: + """Return the hash of the LanguageTag instance. + + If the normalized tag is modified, the hash will change. + + :return: The hash of the normalized tag. + :rtype: int """ - Normalize a language tag to a standard format. + return hash(self.normalized_tag) + + def _normalize(self, tag: str) -> str: + """Normalize a language tag to a standard format. :param tag: The language tag to normalize. :type tag: str diff --git a/language_tool_python/match.py b/language_tool_python/match.py index e00072f..381dd55 100644 --- a/language_tool_python/match.py +++ b/language_tool_python/match.py @@ -1,27 +1,31 @@ """LanguageTool API Match object representation and utility module.""" +from __future__ import annotations + import logging import unicodedata from collections import OrderedDict +from collections import OrderedDict as OrderedDictType from functools import total_ordering -from typing import ( - Any, - Dict, - Iterator, - List, - Optional, - Tuple, -) -from typing import ( - OrderedDict as OrderedDictType, -) +from typing import TYPE_CHECKING + +from ._deprecated import deprecated +from .utils import SupportsFloat, SupportsInt + +if TYPE_CHECKING: + from collections.abc import Iterator + + from .api_types import CheckMatch logger = logging.getLogger(__name__) +UTF8_4_BYTE_LENGTH = 4 +CONTEXT_PREFIX_SUFFIX_LENGTH = 3 +CONTEXT_WITH_ADDITIONS_MIN_LENGTH = 6 + def get_match_ordered_dict() -> OrderedDictType[str, type]: - """ - Returns an ordered dictionary with predefined keys and their corresponding types. + """Return an ordered dictionary with predefined keys and their corresponding types. :return: An OrderedDict where each key is a string representing a specific attribute and each value is the type of that attribute. @@ -56,44 +60,45 @@ def get_match_ordered_dict() -> OrderedDictType[str, type]: ) -def auto_type(obj: Any) -> Any: - """ - Attempts to automatically convert the input object to an integer or float. - If the conversion to an integer fails, it tries to convert to a float. - If both conversions fail, it returns the original object. +@deprecated( + "This function is no longer used internally and will be removed in 4.0.", + stacklevel=2, +) # type: ignore[untyped-decorator, unused-ignore] +def auto_type(obj: SupportsInt | SupportsFloat | object) -> int | float | object: + """Attempt to automatically convert the input object to an integer or float. + + If the conversion to an integer fails, it tries to convert to a float. If both + conversions fail, it returns the original object. :param obj: The object to be converted. - :type obj: Any + :type obj: SupportsInt | SupportsFloat | object :return: The converted object as an integer, float, or the original object. - :rtype: Any + :rtype: int | float | object """ - - try: + if isinstance(obj, SupportsInt): return int(obj) - except ValueError: - try: - return float(obj) - except ValueError: - return obj + if isinstance(obj, SupportsFloat): + return float(obj) + return obj -def four_byte_char_positions(text: str) -> List[int]: - """ - Identify positions of 4-byte encoded characters in a UTF-8 string. - This function scans through the input text and identifies the positions - of characters that are encoded with 4 bytes in UTF-8. These characters - are typically non-BMP (Basic Multilingual Plane) characters, such as - certain emoji and some rare Chinese, Japanese, and Korean characters. +def four_byte_char_positions(text: str) -> list[int]: + """Identify positions of 4-byte encoded characters in a UTF-8 string. + + This function scans through the input text and identifies the positions of + characters that are encoded with 4 bytes in UTF-8. These characters are typically + non-BMP (Basic Multilingual Plane) characters, such as certain emoji and some rare + Chinese, Japanese, and Korean characters. :param text: The input string to be analyzed. :type text: str :return: A list of positions where 4-byte encoded characters are found. - :rtype: List[int] + :rtype: list[int] """ - positions: List[int] = [] + positions: list[int] = [] char_index = 0 for char in text: - if len(char.encode("utf-8")) == 4: + if len(char.encode("utf-8")) == UTF8_4_BYTE_LENGTH: positions.append(char_index) # Adding 1 to the index because 4 byte characters are # 2 bytes in length in LanguageTool, instead of 1 byte in Python. @@ -105,20 +110,16 @@ def four_byte_char_positions(text: str) -> List[int]: @total_ordering -class Match: - """ - Represents a match object that contains information about a language rule violation. - - :param attrib: A dictionary containing various attributes for the match. - The dictionary is expected to have the following keys: - - - ``rule`` (*Dict[str, Any]*): A dictionary with keys ``category`` (which has an ``id``) and ``id``, ``issueType``. - - ``context`` (*Dict[str, Any]*): A dictionary with keys ``offset`` and ``text``. - - ``replacements`` (*List[Dict[str, str]]*): A list of dictionaries, each containing a ``value``. - - ``length`` (*int*): The length of the error. - - ``message`` (*str*): The message describing the error. - :type attrib: Dict[str, Any] - :param text: The original text in which the error occurred (the whole text, not just the context). +class Match: # noqa: PLW1641 # Doesn't implement hash because it's mutable + """Represent a language rule violation match. + + :param attrib: A raw LanguageTool API match. It is expected to contain ``rule`` + (with ``category``, ``id``, and ``issueType``), ``context`` (with ``offset`` + and ``text``), ``replacements`` (items with ``value``), ``length``, and + ``message``. + :type attrib: CheckMatch + :param text: The original text in which the error occurred (the whole text, + not just the context). :type text: str Example of a match object received from the LanguageTool API : @@ -126,26 +127,56 @@ class Match: .. code-block:: python { - 'message': 'Possible spelling mistake found.', - 'shortMessage': 'Spelling mistake', - 'replacements': [{'value': 'newt'}, {'value': 'not'}, {'value': 'new', 'shortDescription': 'having just been made'}, {'value': 'news'}, {'value': 'foot', 'shortDescription': 'singular'}, {'value': 'root', 'shortDescription': 'underground organ of a plant'}, {'value': 'boot'}, {'value': 'noon'}, {'value': 'loot', 'shortDescription': 'plunder'}, {'value': 'moot'}, {'value': 'Root'}, {'value': 'soot', 'shortDescription': 'carbon black'}, {'value': 'newts'}, {'value': 'nook'}, {'value': 'Lieut'}, {'value': 'coot'}, {'value': 'hoot'}, {'value': 'toot'}, {'value': 'snoot'}, {'value': 'neut'}, {'value': 'nowt'}, {'value': 'Noor'}, {'value': 'noob'}], - 'offset': 8, - 'length': 4, - 'context': {'text': 'This is noot okay. ', 'offset': 8, 'length': 4}, 'sentence': 'This is noot okay.', - 'type': {'typeName': 'Other'}, - 'rule': {'id': 'MORFOLOGIK_RULE_EN_US', 'description': 'Possible spelling mistake', 'issueType': 'misspelling', 'category': {'id': 'TYPOS', 'name': 'Possible Typo'}}, - 'ignoreForIncompleteSentence': False, - 'contextForSureMatch': 0 + "message": "Possible spelling mistake found.", + "shortMessage": "Spelling mistake", + "replacements": [ + {"value": "newt"}, + {"value": "not"}, + {"value": "new", "shortDescription": "having just been made"}, + {"value": "news"}, + {"value": "foot", "shortDescription": "singular"}, + {"value": "root", "shortDescription": "underground organ of a plant"}, + {"value": "boot"}, + {"value": "noon"}, + {"value": "loot", "shortDescription": "plunder"}, + {"value": "moot"}, + {"value": "Root"}, + {"value": "soot", "shortDescription": "carbon black"}, + {"value": "newts"}, + {"value": "nook"}, + {"value": "Lieut"}, + {"value": "coot"}, + {"value": "hoot"}, + {"value": "toot"}, + {"value": "snoot"}, + {"value": "neut"}, + {"value": "nowt"}, + {"value": "Noor"}, + {"value": "noob"}, + ], + "offset": 8, + "length": 4, + "context": {"text": "This is noot okay. ", "offset": 8, "length": 4}, + "sentence": "This is noot okay.", + "type": {"typeName": "Other"}, + "rule": { + "id": "MORFOLOGIK_RULE_EN_US", + "description": "Possible spelling mistake", + "issueType": "misspelling", + "category": {"id": "TYPOS", "name": "Possible Typo"}, + }, + "ignoreForIncompleteSentence": False, + "contextForSureMatch": 0, } """ - PREVIOUS_MATCHES_TEXT: Optional[str] = None + PREVIOUS_MATCHES_TEXT: str | None = None """The text of the previous match object.""" - FOUR_BYTES_POSITIONS: Optional[List[int]] = None - """The positions of 4-byte encoded characters in the text, registered by the previous match object - (kept for optimization purposes if the text is the same).""" + FOUR_BYTES_POSITIONS: list[int] | None = None + """The positions of 4-byte encoded characters in the text, registered by the + previous match object (kept for optimization purposes if the text is the same).""" rule_id: str """The ID of the rule that was violated.""" @@ -153,7 +184,7 @@ class Match: message: str """The message describing the error.""" - replacements: List[str] + replacements: list[str] """A list of suggested replacements for the error.""" offset_in_context: int @@ -174,30 +205,33 @@ class Match: rule_issue_type: str """The issue type of the rule that was violated.""" - def __init__(self, attrib: Dict[str, Any], text: str) -> None: - """ - Initialize a Match object with the given attributes. - The method processes and normalizes the attributes before storing them on the object. - This method adjusts the positions of 4-byte encoded characters in the text - to ensure the offsets of the matches are correct. - """ + def __init__(self, attrib: CheckMatch, text: str) -> None: + """Initialize a Match object with the given attributes. + The method processes and normalizes the attributes before storing them on the + object. This method adjusts the positions of 4-byte encoded characters in the + text to ensure the offsets of the matches are correct. + """ # Process rule. - attrib["category"] = attrib["rule"]["category"]["id"] - attrib["rule_id"] = attrib["rule"]["id"] - attrib["rule_issue_type"] = attrib["rule"]["issueType"] - del attrib["rule"] + custom_match: dict[str, str | int | list[str]] = {} + custom_match["category"] = attrib["rule"]["category"]["id"] + custom_match["rule_id"] = attrib["rule"]["id"] + custom_match["rule_issue_type"] = attrib["rule"]["issueType"] + # Process context. - attrib["offset_in_context"] = attrib["context"]["offset"] - attrib["context"] = attrib["context"]["text"] + custom_match["offset_in_context"] = attrib["context"]["offset"] + custom_match["context"] = attrib["context"]["text"] # Process replacements. - attrib["replacements"] = [r["value"] for r in attrib["replacements"]] + custom_match["replacements"] = [r["value"] for r in attrib["replacements"]] # Rename error length. - attrib["error_length"] = attrib["length"] + custom_match["error_length"] = attrib["length"] # Normalize unicode - attrib["message"] = unicodedata.normalize("NFKC", attrib["message"]) + custom_match["message"] = unicodedata.normalize("NFKC", attrib["message"]) + custom_match["sentence"] = attrib["sentence"] + # Store offset before adjusting it for 4-byte characters + custom_match["offset"] = attrib["offset"] # Store objects on self. - for k, v in attrib.items(): + for k, v in custom_match.items(): setattr(self, k, v) if text != Match.PREVIOUS_MATCHES_TEXT: @@ -211,25 +245,26 @@ def __init__(self, attrib: Dict[str, Any], text: str) -> None: ) def __repr__(self) -> str: - """ - Return a string representation of the object. - This method provides a detailed string representation of the object, - including its class name and a dictionary of its attributes. + """Return a string representation of the object. + + This method provides a detailed string representation of the object, including + its class name and a dictionary of its attributes. :return: A string representation of the object. :rtype: str """ def _ordered_dict_repr() -> str: - """ - Generate a string representation of the object's attributes in an ordered dictionary format. + """Return the object's attributes as an ordered dictionary string. - This method collects the attributes of the object, ensuring that the order of attributes - is preserved as defined by ``get_match_ordered_dict()``. Attributes that are not part of the - ordered dictionary are appended at the end. Attributes starting with an underscore are - excluded from the representation. + This method collects the attributes of the object, ensuring that the order + of attributes is preserved as defined by ``get_match_ordered_dict()``. + Attributes that are not part of the ordered dictionary are appended at the + end. Attributes starting with an underscore are excluded from the + representation. - :return: A string representation of the object's attributes in an ordered dictionary format. + :return: A string representation of the object's attributes in an ordered + dictionary format. :rtype: str """ slots = list(get_match_ordered_dict()) @@ -239,16 +274,15 @@ def _ordered_dict_repr() -> str: for slot in slots if slot in self.__dict__ and not slot.startswith("_") ] - return f"{{{', '.join([f'{attr!r}: {getattr(self, attr)!r}' for attr in attrs])}}}" + return f"{{{', '.join([f'{attr!r}: {getattr(self, attr)!r}' for attr in attrs])}}}" # noqa: E501 # Difficult to break this line in python 3.9 return f"{self.__class__.__name__}({_ordered_dict_repr()})" def __str__(self) -> str: - """ - Returns a string representation of the match object. + """Return a string representation of the match object. - The string includes the offset, error length, rule ID, message, - suggestions, and context with a visual indicator of the error position. + The string includes the offset, error length, rule ID, message, suggestions, and + context with a visual indicator of the error position. :return: A formatted string describing the match object. :rtype: str @@ -259,13 +293,15 @@ def __str__(self) -> str: s += f"\nMessage: {self.message}" if self.replacements: s += f"\nSuggestion: {'; '.join(self.replacements)}" - s += f"\n{self.context}\n{' ' * self.offset_in_context + '^' * self.error_length}" + s += ( + f"\n{self.context}\n" + f"{' ' * self.offset_in_context + '^' * self.error_length}" + ) return s @property def matched_text(self) -> str: - """ - Returns the substring from the context that corresponds to the matched text. + """Return the substring from the context that corresponds to the matched text. :return: The matched text from the context. :rtype: str @@ -274,18 +310,21 @@ def matched_text(self) -> str: self.offset_in_context : self.offset_in_context + self.error_length ] - def get_line_and_column(self, original_text: str) -> Tuple[int, int]: - """ - Returns the line and column number of the error in the context. + def get_line_and_column(self, original_text: str) -> tuple[int, int]: + """Return the line and column number of the error in the context. - :param original_text: The original text in which the error occurred. We need this to calculate the line and column number, because the context has no more newline characters. + :param original_text: The original text in which the error occurred. We need + this to calculate the line and column number, because the context has no + more newline characters. :type original_text: str :return: A tuple containing the line and column number of the error. - :rtype: Tuple[int, int] + :rtype: tuple[int, int] + :raises ValueError: If the original text does not contain the match context. """ - context_without_additions = ( - self.context[3:-3] if len(self.context) > 6 else self.context + self.context[CONTEXT_PREFIX_SUFFIX_LENGTH:-CONTEXT_PREFIX_SUFFIX_LENGTH] + if len(self.context) > CONTEXT_WITH_ADDITIONS_MIN_LENGTH + else self.context ) if context_without_additions not in original_text.replace("\n", " "): err = "The original text does not match the context of the error" @@ -295,90 +334,93 @@ def get_line_and_column(self, original_text: str) -> Tuple[int, int]: return line + 1, column def select_replacement(self, index: int) -> None: - """ - Select a single replacement suggestion based on the given index and update the replacements list, leaving only the selected replacement. + """Keep only the replacement selected by the given index. :param index: The index of the replacement to select. :type index: int :raises ValueError: If there are no replacement suggestions. :raises ValueError: If the index is out of the valid range. """ - if not self.replacements: err = "This Match has no suggestions" raise ValueError(err) if index < 0 or index >= len(self.replacements): - err = f"This Match's suggestions are numbered from 0 to {len(self.replacements) - 1}" + err = ( + f"This Match's suggestions are numbered from 0" + f"to {len(self.replacements) - 1}" + ) raise ValueError(err) self.replacements = [self.replacements[index]] - def __eq__(self, other: Any) -> bool: - """ - Compare this object with another for equality. + def __eq__(self, other: object) -> bool: + """Compare this object with another for equality. :param other: The object to compare with. - :type other: Any + :type other: object :return: True if both objects are equal, False otherwise. :rtype: bool """ + if not isinstance(other, Match): + return NotImplemented return list(self) == list(other) - def __lt__(self, other: Any) -> bool: - """ - Compare this object with another object for less-than ordering. + def __lt__(self, other: object) -> bool: + """Compare this object with another object for less-than ordering. :param other: The object to compare with. - :type other: Any + :type other: object :return: True if this object is less than the other object, False otherwise. :rtype: bool """ + if not isinstance(other, Match): + return NotImplemented return list(self) < list(other) - def __iter__(self) -> Iterator[Any]: - """ - Return an iterator over the attributes of the match object. + def __iter__(self) -> Iterator[str | int | list[str]]: + """Return an iterator over the attributes of the match object. - This method allows the match object to be iterated over, yielding the - values of its attributes in the order defined by ``get_match_ordered_dict()``. + This method allows the match object to be iterated over, yielding the values of + its attributes in the order defined by ``get_match_ordered_dict()``. :return: An iterator over the attribute values of the match object. - :rtype: Iterator[Any] + :rtype: Iterator[str | int | list[str]] """ return iter(getattr(self, attr) for attr in get_match_ordered_dict()) - def __setattr__(self, key: str, value: Any) -> None: - """ - Set an attribute on the instance. + def __setattr__(self, key: str, value: str | int | list[str]) -> None: + """Set an attribute on the instance. - This method overrides the default behavior of setting an attribute. - It attempts to transform the value using a function from ``get_match_ordered_dict()`` - based on the provided key. If the key is not found in the dictionary, the attribute - is not set. + This method overrides the default behavior of setting an attribute. It attempts + to transform the value using a function from ``get_match_ordered_dict()`` based + on the provided key. If the key is not found in the dictionary, the attribute is + not set. :param key: The name of the attribute to set. :type key: str :param value: The value to set the attribute to. - :type value: Any + :type value: str | int | list[str] """ try: + # Ex: if key is "offset", get_match_ordered_dict()[key] will return int, so + # the value will be transformed to int value = get_match_ordered_dict()[key](value) except KeyError: return super().__setattr__(key, value) - def __getattr__(self, name: str) -> Any: - """ - Handle attribute access for undefined attributes. + def __getattr__(self, name: str) -> None: + """Handle attribute access for undefined attributes. - This method is called when an attribute lookup has not found the attribute in the usual places - (i.e., it is not an instance attribute nor is it found in the class tree for self). This method - checks if the attribute name is in the ordered dictionary returned by ``get_match_ordered_dict()``. - If the attribute name is not found, it raises an AttributeError. + This method is called when an attribute lookup has not found the attribute in + the usual places (i.e., it is not an instance attribute nor is it found in the + class tree for self). This method checks if the attribute name is in the ordered + dictionary returned by ``get_match_ordered_dict()``. If the attribute name is + not found, it raises an AttributeError. :param name: The name of the attribute being accessed. :type name: str - :return: The value of the attribute if it exists. - :rtype: Any + :return: None for known unset match fields. + :rtype: None :raises AttributeError: If the attribute does not exist. """ if name not in get_match_ordered_dict(): diff --git a/language_tool_python/safe_zip.py b/language_tool_python/safe_zip.py index 1d96398..5f85ed2 100644 --- a/language_tool_python/safe_zip.py +++ b/language_tool_python/safe_zip.py @@ -1,21 +1,27 @@ """Safe ZIP extraction utilities.""" +from __future__ import annotations + import contextlib import logging import re import shutil import stat import tempfile -import zipfile from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Optional +from typing import TYPE_CHECKING from .exceptions import PathError from .utils import get_env_float, get_env_int +if TYPE_CHECKING: + import zipfile + logger = logging.getLogger(__name__) +CONTROL_CHARACTER_MAX = 32 + LTP_SAFE_ZIP_MAX_ARCHIVE_BYTES_ENV_VAR = "LTP_SAFE_ZIP_MAX_ARCHIVE_BYTES" LTP_SAFE_ZIP_MAX_EXTRACTED_BYTES_ENV_VAR = "LTP_SAFE_ZIP_MAX_EXTRACTED_BYTES" LTP_SAFE_ZIP_MAX_MEMBERS_ENV_VAR = "LTP_SAFE_ZIP_MAX_MEMBERS" @@ -29,10 +35,12 @@ "LTP_SAFE_ZIP_MAX_TOTAL_COMPRESSION_RATIO" ) DEFAULT_MAX_ARCHIVE_BYTES = get_env_int( - LTP_SAFE_ZIP_MAX_ARCHIVE_BYTES_ENV_VAR, 512 * 1024 * 1024 + LTP_SAFE_ZIP_MAX_ARCHIVE_BYTES_ENV_VAR, + 512 * 1024 * 1024, ) # 512 MiB, latest snapshot: 246.15 MiB compressed members DEFAULT_MAX_EXTRACTED_BYTES = get_env_int( - LTP_SAFE_ZIP_MAX_EXTRACTED_BYTES_ENV_VAR, 768 * 1024 * 1024 + LTP_SAFE_ZIP_MAX_EXTRACTED_BYTES_ENV_VAR, + 768 * 1024 * 1024, ) # 768 MiB, latest snapshot: 394.48 MiB extracted DEFAULT_MAX_MEMBERS = get_env_int( LTP_SAFE_ZIP_MAX_MEMBERS_ENV_VAR, @@ -40,7 +48,8 @@ ) # latest snapshot: 2,051 members DEFAULT_COPY_CHUNK_BYTES = 1024 * 1024 # I/O chunk size DEFAULT_MAX_MEMBER_EXTRACTED_BYTES = get_env_int( - LTP_SAFE_ZIP_MAX_MEMBER_EXTRACTED_BYTES_ENV_VAR, 128 * 1024 * 1024 + LTP_SAFE_ZIP_MAX_MEMBER_EXTRACTED_BYTES_ENV_VAR, + 128 * 1024 * 1024, ) # 128 MiB, latest snapshot: 32.91 MiB largest member DEFAULT_MAX_MEMBER_COMPRESSION_RATIO = get_env_float( LTP_SAFE_ZIP_MAX_MEMBER_COMPRESSION_RATIO_ENV_VAR, @@ -62,8 +71,7 @@ @dataclass(frozen=True) class SafeZipLimits: - """ - Limits applied while validating and extracting a ZIP archive. + """Limits applied while validating and extracting a ZIP archive. Values are expressed in bytes unless otherwise stated. """ @@ -78,16 +86,13 @@ class SafeZipLimits: class SafeZipExtractor: - """ - Extract ZIP archives after validating paths, member types, and size limits. - """ + """Extract ZIP archives after validating paths, member types, and size limits.""" - def __init__(self, limits: Optional[SafeZipLimits] = None) -> None: - """ - Initialize the safe extractor. + def __init__(self, limits: SafeZipLimits | None = None) -> None: + """Initialize the safe extractor. :param limits: Optional extraction limits. Defaults to SafeZipLimits(). - :type limits: Optional[SafeZipLimits] + :type limits: SafeZipLimits | None """ self.limits = limits or SafeZipLimits() @@ -95,20 +100,19 @@ def extractall( self, zip_file: zipfile.ZipFile, destination: Path, - work_dir: Optional[Path] = None, + work_dir: Path | None = None, ) -> None: - """ - Safely extract all ZIP members into destination. + """Safely extract all ZIP members into destination. - Extraction first happens inside a private directory, then validated - top-level entries are moved into the final destination. + Extraction first happens inside a private directory, then validated top-level + entries are moved into the final destination. :param zip_file: The open ZIP archive to extract. :type zip_file: zipfile.ZipFile :param destination: Directory where ZIP contents should be placed. :type destination: Path :param work_dir: Optional parent directory for temporary extraction. - :type work_dir: Optional[Path] + :type work_dir: Path | None :raises PathError: If the archive or destination is unsafe. """ destination = Path(destination) @@ -129,8 +133,7 @@ def extractall( logger.debug("Completed safe ZIP extraction to %s", destination) def _normalize_member_path(self, filename: str) -> PurePosixPath: - """ - Normalize and validate a ZIP member path. + """Normalize and validate a ZIP member path. :param filename: Raw ZIP member filename. :type filename: str @@ -142,7 +145,7 @@ def _normalize_member_path(self, filename: str) -> PurePosixPath: err = f"Unsafe ZIP member name: {filename!r}." raise PathError(err) - if any(ord(character) < 32 for character in filename): + if any(ord(character) < CONTROL_CHARACTER_MAX for character in filename): err = f"Unsafe ZIP member name: {filename!r}." raise PathError(err) @@ -181,8 +184,7 @@ def _normalize_member_path(self, filename: str) -> PurePosixPath: return member_path def _validate_member_type(self, member: zipfile.ZipInfo) -> None: - """ - Reject symlinks and unsupported ZIP member types. + """Reject symlinks and unsupported ZIP member types. :param member: ZIP member metadata. :type member: zipfile.ZipInfo @@ -208,8 +210,7 @@ def _validate_member_type(self, member: zipfile.ZipInfo) -> None: raise PathError(err) def _validate_member_compression_ratio(self, member: zipfile.ZipInfo) -> None: - """ - Reject a member with a suspicious compression ratio. + """Reject a member with a suspicious compression ratio. :param member: ZIP member metadata. :type member: zipfile.ZipInfo @@ -237,8 +238,7 @@ def _validate_member_compression_ratio(self, member: zipfile.ZipInfo) -> None: raise PathError(err) def _zip_target(self, destination: Path, member_path: PurePosixPath) -> Path: - """ - Resolve a member target and ensure it stays inside destination. + """Resolve a member target and ensure it stays inside destination. :param destination: Extraction root directory. :type destination: Path @@ -260,12 +260,138 @@ def _zip_target(self, destination: Path, member_path: PurePosixPath) -> Path: return target + def _member_path_key(self, member_path: PurePosixPath) -> str: + """Return the normalized key used to detect path conflicts. + + :param member_path: Normalized ZIP member path. + :type member_path: PurePosixPath + :return: Case-folded POSIX path key. + :rtype: str + """ + return "/".join(part.casefold() for part in member_path.parts) + + def _validate_member_path_conflicts( + self, + member: zipfile.ZipInfo, + member_path: PurePosixPath, + seen_paths: set[str], + seen_file_paths: set[str], + ) -> None: + """Reject duplicate paths and file/directory path conflicts. + + :param member: ZIP member metadata. + :type member: zipfile.ZipInfo + :param member_path: Normalized ZIP member path. + :type member_path: PurePosixPath + :param seen_paths: Case-folded paths already seen in the archive. + :type seen_paths: set[str] + :param seen_file_paths: Case-folded file paths already seen in the archive. + :type seen_file_paths: set[str] + :raises PathError: If the member conflicts with another archive path. + """ + path_key = self._member_path_key(member_path) + + if path_key in seen_paths: + err = f"Refusing to extract duplicate ZIP member path: {member.filename!r}." + raise PathError(err) + + seen_paths.add(path_key) + + ancestor_keys = [ + self._member_path_key(PurePosixPath(*member_path.parts[:index])) + for index in range(1, len(member_path.parts)) + ] + if any(ancestor in seen_file_paths for ancestor in ancestor_keys): + err = ( + f"Refusing to extract ZIP member below file path: {member.filename!r}." + ) + raise PathError(err) + + if not member.is_dir(): + descendant_prefix = f"{path_key}/" + if any(existing.startswith(descendant_prefix) for existing in seen_paths): + err = ( + f"Refusing to extract ZIP file over directory path: " + f"{member.filename!r}." + ) + raise PathError(err) + seen_file_paths.add(path_key) + + def _validate_member_sizes(self, member: zipfile.ZipInfo) -> None: + """Validate a member's declared type, sizes, and compression ratio. + + :param member: ZIP member metadata. + :type member: zipfile.ZipInfo + :raises PathError: If the member metadata is unsafe. + """ + self._validate_member_type(member) + + if member.compress_size < 0 or member.file_size < 0: + err = f"Invalid ZIP member size: {member.filename!r}." + raise PathError(err) + + self._validate_member_compression_ratio(member) + + def _validate_archive_size_totals( + self, + total_compressed: int, + total_uncompressed: int, + ) -> None: + """Validate accumulated compressed and uncompressed archive sizes. + + :param total_compressed: Sum of compressed member sizes seen so far. + :type total_compressed: int + :param total_uncompressed: Sum of uncompressed member sizes seen so far. + :type total_uncompressed: int + :raises PathError: If an archive size limit is exceeded. + """ + if total_compressed > self.limits.max_archive_bytes: + err = ( + f"Refusing to extract ZIP archive with {total_compressed} " + f"compressed member bytes. Maximum allowed size is " + f"{self.limits.max_archive_bytes} bytes." + ) + raise PathError(err) + + if total_uncompressed > self.limits.max_extracted_bytes: + err = ( + f"Refusing to extract {total_uncompressed} bytes. " + f"Maximum allowed extracted size is " + f"{self.limits.max_extracted_bytes} bytes." + ) + raise PathError(err) + + def _validate_total_compression_ratio( + self, + total_compressed: int, + total_uncompressed: int, + ) -> None: + """Reject an archive with a suspicious total compression ratio. + + :param total_compressed: Sum of compressed member sizes. + :type total_compressed: int + :param total_uncompressed: Sum of uncompressed member sizes. + :type total_uncompressed: int + :raises PathError: If the total compression ratio is too high. + """ + if total_compressed == 0: + return + + total_ratio = total_uncompressed / total_compressed + + if total_ratio > self.limits.max_total_compression_ratio: + err = ( + f"Refusing ZIP archive with suspicious total compression ratio " + f"{total_ratio:.1f}. Maximum allowed ratio is " + f"{self.limits.max_total_compression_ratio:.1f}." + ) + raise PathError(err) + def _validate_members( self, members: list[zipfile.ZipInfo], ) -> list[tuple[zipfile.ZipInfo, PurePosixPath]]: - """ - Validate all ZIP members before writing any file. + """Validate all ZIP members before writing any file. :param members: ZIP members to validate. :type members: list[zipfile.ZipInfo] @@ -288,79 +414,22 @@ def _validate_members( for member in members: member_path = self._normalize_member_path(member.filename) - path_key = "/".join(part.casefold() for part in member_path.parts) - - if path_key in seen_paths: - err = ( - f"Refusing to extract duplicate ZIP member path: " - f"{member.filename!r}." - ) - raise PathError(err) - - seen_paths.add(path_key) - - ancestor_keys = [ - "/".join(part.casefold() for part in member_path.parts[:index]) - for index in range(1, len(member_path.parts)) - ] - if any(ancestor in seen_file_paths for ancestor in ancestor_keys): - err = ( - f"Refusing to extract ZIP member below file path: " - f"{member.filename!r}." - ) - raise PathError(err) - - if not member.is_dir(): - descendant_prefix = f"{path_key}/" - if any( - existing.startswith(descendant_prefix) for existing in seen_paths - ): - err = ( - f"Refusing to extract ZIP file over directory path: " - f"{member.filename!r}." - ) - raise PathError(err) - seen_file_paths.add(path_key) - - self._validate_member_type(member) - - if member.compress_size < 0 or member.file_size < 0: - err = f"Invalid ZIP member size: {member.filename!r}." - raise PathError(err) - - self._validate_member_compression_ratio(member) + self._validate_member_path_conflicts( + member, + member_path, + seen_paths, + seen_file_paths, + ) + self._validate_member_sizes(member) total_compressed += member.compress_size total_uncompressed += member.file_size - if total_compressed > self.limits.max_archive_bytes: - err = ( - f"Refusing to extract ZIP archive with {total_compressed} " - f"compressed member bytes. Maximum allowed size is " - f"{self.limits.max_archive_bytes} bytes." - ) - raise PathError(err) - - if total_uncompressed > self.limits.max_extracted_bytes: - err = ( - f"Refusing to extract {total_uncompressed} bytes. " - f"Maximum allowed extracted size is " - f"{self.limits.max_extracted_bytes} bytes." - ) - raise PathError(err) + self._validate_archive_size_totals(total_compressed, total_uncompressed) validated_members.append((member, member_path)) - if total_compressed > 0: - total_ratio = total_uncompressed / total_compressed - - if total_ratio > self.limits.max_total_compression_ratio: - err = ( - f"Refusing ZIP archive with suspicious total compression ratio " - f"{total_ratio:.1f}. Maximum allowed ratio is " - f"{self.limits.max_total_compression_ratio:.1f}." - ) - raise PathError(err) + self._validate_total_compression_ratio(total_compressed, total_uncompressed) logger.debug( "Validated ZIP archive: members=%d, compressed=%d bytes, " @@ -373,8 +442,7 @@ def _validate_members( return validated_members def _ensure_safe_parent(self, destination: Path, target: Path) -> None: - """ - Ensure the target parent is inside destination and not symlinked. + """Ensure the target parent is inside destination and not symlinked. :param destination: Extraction root directory. :type destination: Path @@ -424,8 +492,7 @@ def _copy_member( member: zipfile.ZipInfo, target: Path, ) -> None: - """ - Copy one validated file member without overwriting existing paths. + """Copy one validated file member without overwriting existing paths. :param zip_file: The open ZIP archive. :type zip_file: zipfile.ZipFile @@ -450,7 +517,7 @@ def _copy_member( try: with ( zip_file.open(member, "r") as source, - open(target, "xb") as target_file, + target.open("xb") as target_file, ): while True: chunk = source.read(self.limits.copy_chunk_bytes) @@ -497,8 +564,7 @@ def _extract_to_private_directory( zip_file: zipfile.ZipFile, destination: Path, ) -> None: - """ - Extract validated members into a private temporary directory. + """Extract validated members into a private temporary directory. :param zip_file: The open ZIP archive. :type zip_file: zipfile.ZipFile @@ -557,8 +623,7 @@ def _extract_to_private_directory( logger.debug("Finished extracting ZIP members into %s", destination) def _make_private_extract_dir(self, base_dir: Path) -> Path: - """ - Create a private temporary directory under base_dir. + """Create a private temporary directory under base_dir. :param base_dir: Parent directory for temporary extraction. :type base_dir: Path @@ -579,7 +644,7 @@ def _make_private_extract_dir(self, base_dir: Path) -> Path: tempfile.mkdtemp( prefix="zip-extract-", dir=base_dir, - ) + ), ) with contextlib.suppress(OSError): @@ -594,8 +659,7 @@ def _extractall_to_directory( final_directory: Path, private_work_dir: Path, ) -> None: - """ - Extract into a private directory and move safe top-level entries. + """Extract into a private directory and move safe top-level entries. :param zip_file: The open ZIP archive. :type zip_file: zipfile.ZipFile @@ -613,7 +677,10 @@ def _extractall_to_directory( final_directory.mkdir(parents=True, exist_ok=True) if final_directory.is_symlink(): - err = f"Refusing to extract into symlinked destination: {final_directory}." + err = ( + f"Refusing to extract into symlinked" + f" destination: {final_directory}." + ) raise PathError(err) final_directory_resolved = final_directory.resolve(strict=True) diff --git a/language_tool_python/server.py b/language_tool_python/server.py index 231ed01..9e18471 100644 --- a/language_tool_python/server.py +++ b/language_tool_python/server.py @@ -1,10 +1,13 @@ """LanguageTool server management module.""" +from __future__ import annotations + import atexit import contextlib import http.client import json import logging +import os import random import re import socket @@ -12,13 +15,16 @@ import time import urllib.parse import warnings -from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Set +from typing import TYPE_CHECKING, ClassVar, Literal, cast import psutil import requests from packaging.version import Version +from .api_types import ( + is_check_response, + is_language_info, +) from .config_file import LanguageToolConfig from .download_lt import LTP_DOWNLOAD_VERSION, LocalLanguageTool from .exceptions import ( @@ -35,24 +41,38 @@ get_locale_language, kill_process_force, parse_url, - startupinfo, ) +startupinfo: object = None +if os.name == "nt": + from .utils import startupinfo + +if TYPE_CHECKING: + from collections.abc import Mapping + from pathlib import Path + from types import TracebackType + + from .api_types import CheckResponse, LanguageInfo + from .config_file import ConfigValue + logger = logging.getLogger(__name__) +HTTP_STATUS_RATE_LIMIT = 426 + # Keep track of running server PIDs in a global list. This way, # we can ensure they're killed on exit. -RUNNING_SERVER_PROCESSES: List[subprocess.Popen[str]] = [] +RUNNING_SERVER_PROCESSES: list[subprocess.Popen[str]] = [] -def _kill_processes(processes: List[subprocess.Popen[str]]) -> None: - """ - Kill all running server processes. - This function iterates over the list of running server processes and - forcefully kills each process by its PID. +def _kill_processes(processes: list[subprocess.Popen[str]]) -> None: + """Kill all running server processes. - :param processes: A list of subprocess.Popen objects representing the running server processes. - :type processes: List[subprocess.Popen] + This function iterates over the list of running server processes and forcefully + kills each process by its PID. + + :param processes: A list of subprocess.Popen objects representing the running server + processes. + :type processes: list[subprocess.Popen[str]] """ for p in processes: with contextlib.suppress(psutil.NoSuchProcess): @@ -63,36 +83,54 @@ def _kill_processes(processes: List[subprocess.Popen[str]]) -> None: class LanguageTool: - """ - A class to interact with the LanguageTool server for text checking and correction. + """Interact with the LanguageTool server for text checking and correction. - :param language: The language to be used by the LanguageTool server. If None, it will try to detect the system language. - :type language: Optional[str] + :param language: The language to be used by the LanguageTool server. If None, it + will try to detect the system language. + :type language: str | None :param mother_tongue: The mother tongue of the user. - :type mother_tongue: Optional[str] - :param remote_server: URL of a remote LanguageTool server. If provided, the local server will not be started. - :type remote_server: Optional[str] + :type mother_tongue: str | None + :param remote_server: URL of a remote LanguageTool server. If provided, the local + server will not be started. + :type remote_server: str | None :param new_spellings: Custom spellings to be added to the LanguageTool server. - :type new_spellings: Optional[List[str]] - :param new_spellings_persist: Whether the new spellings should persist across sessions. - :type new_spellings_persist: Optional[bool] + :type new_spellings: list[str] | None + :param new_spellings_persist: Whether the new spellings should persist across + sessions. + :type new_spellings_persist: bool :param host: The host address for the LanguageTool server. Defaults to 'localhost'. - :type host: Optional[str] - :param config: Path to a configuration file for the LanguageTool server. - :type config: Optional[str] - :param language_tool_download_version: The version of LanguageTool to download if needed. - :type language_tool_download_version: Optional[str] - :param proxies: A dictionary of proxies to use for server requests (e.g., {'http': 'http://proxy:port', 'https': 'https://proxy:port'}). - :type proxies: Optional[Dict[str, str]] + :type host: str | None + :param config: Configuration options for the local LanguageTool server. + :type config: Mapping[str, ConfigValue] | None + :param language_tool_download_version: The version of LanguageTool to download if + needed. + :type language_tool_download_version: str + :param proxies: A dictionary of proxies to use for server requests (e.g., {'http': + 'http://proxy:port', 'https': 'https://proxy:port'}). + :type proxies: dict[str, str] | None + :raises ValueError: If incompatible constructor parameters are combined or the + language tag is unsupported. + :raises TypeError: If a config value has an unsupported type. + :raises PathError: If config paths are invalid, the local LanguageTool + installation cannot be prepared, or custom spellings are requested without a + local LanguageTool installation. + :raises ModuleNotFoundError: If no Java installation is detected for a local + server. + :raises SystemError: If the detected Java version is incompatible with the + requested LanguageTool version. + :raises TimeoutError: If the LanguageTool download request times out. + :raises ServerError: If the server does not become ready or returns an invalid + response while initializing. + :raises LanguageToolError: If the server cannot be queried while initializing. """ - _available_ports: List[int] + _available_ports: list[int] """A list of available ports for the server, shuffled randomly.""" _TIMEOUT: Literal[300] = 300 """The timeout for server requests.""" - _SPELL_CHECKING_CATEGORIES: Set[str] = {"TYPOS"} + _SPELL_CHECKING_CATEGORIES: ClassVar[set[str]] = {"TYPOS"} """Categories used for spell checking.""" _remote: bool @@ -101,16 +139,16 @@ class LanguageTool: _port: int """The port number to use for the server.""" - _server: Optional[subprocess.Popen[str]] + _server: subprocess.Popen[str] | None """The server process.""" _language_tool_download_version: str """The version of LanguageTool to download.""" - _local_language_tool: Optional[LocalLanguageTool] + _local_language_tool: LocalLanguageTool | None """The LocalLanguageTool instance.""" - _new_spellings: Optional[List[str]] + _new_spellings: list[str] | None """A list of new spellings to register.""" _new_spellings_persist: bool @@ -119,31 +157,32 @@ class LanguageTool: _host: str """The host to use for the server.""" - _config: Optional[LanguageToolConfig] + _config: LanguageToolConfig | None """The server configuration options (used when starting the local server).""" _url: str """The base URL of the LanguageTool server (used in all server requests).""" - _mother_tongue: Optional[str] - """The user's mother tongue for better error detection (used in requests to the server).""" + _mother_tongue: str | None + """The user's mother tongue for better error detection (used in requests to the + server).""" - _disabled_rules: Set[str] + _disabled_rules: set[str] """A set of disabled grammar/style rules (used in requests to the server).""" - _enabled_rules: Set[str] + _enabled_rules: set[str] """A set of explicitly enabled rules (used in requests to the server).""" - _disabled_categories: Set[str] + _disabled_categories: set[str] """A set of disabled rule categories (used in requests to the server).""" - _enabled_categories: Set[str] + _enabled_categories: set[str] """A set of explicitly enabled categories (used in requests to the server).""" _enabled_rules_only: bool """A flag to use only explicitly enabled rules (used in requests to the server).""" - _preferred_variants: Set[str] + _preferred_variants: set[str] """A set of preferred language variants (used in requests to the server).""" _picky: bool @@ -152,23 +191,37 @@ class LanguageTool: _language: LanguageTag """The language to use for text checking (used in requests to the server).""" - _proxies: Optional[Dict[str, str]] + _proxies: dict[str, str] | None """A dictionary of proxies for network requests (used in requests to the server).""" - def __init__( + def __init__( # noqa: PLR0913 # Too many arguments, but they are all necessary for configuring the server. Maybe refactor in a future breaking release to use a configuration object instead of individual parameters. self, - language: Optional[str] = None, - mother_tongue: Optional[str] = None, - remote_server: Optional[str] = None, - new_spellings: Optional[List[str]] = None, + language: str | None = None, + mother_tongue: str | None = None, + remote_server: str | None = None, + new_spellings: list[str] | None = None, new_spellings_persist: bool = True, - host: Optional[str] = None, - config: Optional[Dict[str, Any]] = None, + host: str | None = None, + config: Mapping[str, ConfigValue] | None = None, language_tool_download_version: str = LTP_DOWNLOAD_VERSION, - proxies: Optional[Dict[str, str]] = None, + proxies: dict[str, str] | None = None, ) -> None: - """ - Initialize the LanguageTool server. + """Initialize the LanguageTool server. + + :raises ValueError: If incompatible parameters are combined or the language + tag is unsupported. + :raises TypeError: If a config value has an unsupported type. + :raises PathError: If config paths are invalid, the local LanguageTool + installation cannot be prepared, or custom spellings are requested without + a local LanguageTool installation. + :raises ModuleNotFoundError: If no Java installation is detected for a local + server. + :raises SystemError: If the detected Java version is incompatible with the + requested LanguageTool version. + :raises TimeoutError: If the LanguageTool download request times out. + :raises ServerError: If the server does not become ready or returns an invalid + response while initializing. + :raises LanguageToolError: If the server cannot be queried while initializing. """ self._remote = False self._language_tool_download_version = language_tool_download_version @@ -220,9 +273,8 @@ def __init__( self._preferred_variants = set() self._picky = False - def __enter__(self) -> "LanguageTool": - """ - Enter the runtime context related to this object. + def __enter__(self) -> LanguageTool: + """Enter the runtime context related to this object. This method is called when execution flow enters the context of the ``with`` statement using this object. It returns the object itself. @@ -234,35 +286,35 @@ def __enter__(self) -> "LanguageTool": def __exit__( self, - exc_type: Optional[type], - exc_val: Optional[BaseException], - exc_tb: Optional[Any], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: - """ - Exit the runtime context related to this object. + """Exit the runtime context related to this object. + This method is called when the runtime context is exited. It can be used to clean up any resources that were allocated during the context. The parameters - describe the exception that caused the context to be exited. If the context - was exited without an exception, all three arguments will be None. - - :param exc_type: The exception type of the exception that caused the context - to be exited, or None if no exception occurred. - :type exc_type: Optional[type] - :param exc_val: The exception instance that caused the context to be exited, - or None if no exception occurred. - :type exc_val: Optional[BaseException] - :param exc_tb: The traceback object associated with the exception, or None - if no exception occurred. - :type exc_tb: Optional[Any] + describe the exception that caused the context to be exited. If the context was + exited without an exception, all three arguments will be None. + + :param exc_type: The exception type of the exception that caused the context to + be exited, or None if no exception occurred. + :type exc_type: type[BaseException] | None + :param exc_val: The exception instance that caused the context to be exited, or + None if no exception occurred. + :type exc_val: BaseException | None + :param exc_tb: The traceback object associated with the exception, or None if no + exception occurred. + :type exc_tb: TracebackType | None """ self.close() def __del__(self) -> None: - """ - Destructor method that ensures the server is properly closed. - This method is called when the instance is about to be destroyed. It - ensures that the ``close`` method is called to release any resources - or perform any necessary cleanup. + """Destructor method that ensures the server is properly closed. + + This method is called when the instance is about to be destroyed. It ensures + that the ``close`` method is called to release any resources or perform any + necessary cleanup. """ if self._server_is_alive(): warnings.warn("unclosed server", ResourceWarning, stacklevel=2) @@ -273,17 +325,18 @@ def __del__(self) -> None: self.close() def __repr__(self) -> str: - """ - Return a string representation of the server instance. + """Return a string representation of the server instance. :return: A string that includes the class name, language, and mother tongue. :rtype: str """ - return f"{self.__class__.__name__}(language={self._language!r}, motherTongue={self._mother_tongue!r})" + return ( + f"{self.__class__.__name__}(language={self._language!r}" + f", motherTongue={self._mother_tongue!r})" + ) def close(self) -> None: - """ - Closes the server and performs necessary cleanup operations. + """Close the server and perform necessary cleanup operations. This method performs the following actions: 1. Checks if the server is alive, not remote and terminates it if necessary. @@ -298,75 +351,75 @@ def close(self) -> None: @property def language(self) -> LanguageTag: - """ - Get the language tag associated with the server. + """Get the language tag associated with the server. :return: The language tag. :rtype: LanguageTag """ - return self._language @language.setter def language(self, language: str) -> None: - """ - Set the language for the language tool. + """Set the language for the language tool. :param language: The language code to set. :type language: str + :raises ValueError: If the language tag is unsupported. + :raises LanguageToolError: If supported languages cannot be fetched from the + server. """ - self._language = LanguageTag(language, self._get_languages()) self._disabled_rules.clear() self._enabled_rules.clear() @property - def mother_tongue(self) -> Optional[LanguageTag]: - """ - Get the mother tongue language tag. + def mother_tongue(self) -> LanguageTag | None: + """Get the mother tongue language tag. :return: The mother tongue language tag if set, otherwise None. - :rtype: Optional[LanguageTag] + :rtype: LanguageTag | None + :raises ValueError: If the mother tongue tag is unsupported. + :raises LanguageToolError: If supported languages cannot be fetched from the + server. """ if self._mother_tongue is not None: return LanguageTag(self._mother_tongue, self._get_languages()) return None @mother_tongue.setter - def mother_tongue(self, mother_tongue: Optional[str]) -> None: - """ - Set the mother tongue for the language tool. + def mother_tongue(self, mother_tongue: str | None) -> None: + """Set the mother tongue for the language tool. - The mother tongue helps LanguageTool detect false friends (words that look similar - between two languages but have different meanings). This feature works for specific - language pairs (e.g., detecting English-like words used incorrectly in German). + The mother tongue helps LanguageTool detect false friends (words that look + similar between two languages but have different meanings). This feature works + for specific language pairs (e.g., detecting English-like words used incorrectly + in German). - :param mother_tongue: The mother tongue language tag as a string. If None, the mother tongue is set to None. - :type mother_tongue: Optional[str] + :param mother_tongue: The mother tongue language tag as a string. If None, the + mother tongue is set to None. + :type mother_tongue: str | None """ - self._mother_tongue = mother_tongue @property - def proxies(self) -> Optional[Dict[str, str]]: - """ - Get the proxies used for server requests. + def proxies(self) -> dict[str, str] | None: + """Get the proxies used for server requests. :return: A dictionary of proxies if set, otherwise None. - :rtype: Optional[Dict[str, str]] + :rtype: dict[str, str] | None """ return self._proxies @proxies.setter - def proxies(self, proxies: Optional[Dict[str, str]]) -> None: - """ - Set the proxies for server requests. + def proxies(self, proxies: dict[str, str] | None) -> None: + """Set the proxies for server requests. - Proxies can only be used with remote servers. Local LanguageTool servers - do not support proxy configuration. + Proxies can only be used with remote servers. Local LanguageTool servers do not + support proxy configuration. - :param proxies: A dictionary of proxies (e.g., {'http': 'http://proxy:port'}), or None to unset. - :type proxies: Optional[Dict[str, str]] + :param proxies: A dictionary of proxies (e.g., {'http': 'http://proxy:port'}), + or None to unset. + :type proxies: dict[str, str] | None :raises ValueError: If trying to set proxies on a local server. """ if proxies is not None and not self._remote: @@ -378,89 +431,80 @@ def proxies(self, proxies: Optional[Dict[str, str]]) -> None: self._proxies = proxies @property - def disabled_rules(self) -> Set[str]: - """ - Get the set of disabled rules. + def disabled_rules(self) -> set[str]: + """Get the set of disabled rules. :return: A set of disabled rule IDs. - :rtype: Set[str] + :rtype: set[str] """ return self._disabled_rules @disabled_rules.setter - def disabled_rules(self, value: Set[str]) -> None: - """ - Set the rules to disable during text checking. + def disabled_rules(self, value: set[str]) -> None: + """Set the rules to disable during text checking. :param value: A set of rule IDs to disable. - :type value: Set[str] + :type value: set[str] """ self._disabled_rules = value @property - def enabled_rules(self) -> Set[str]: - """ - Get the set of enabled rules. + def enabled_rules(self) -> set[str]: + """Get the set of enabled rules. :return: A set of enabled rule IDs. - :rtype: Set[str] + :rtype: set[str] """ return self._enabled_rules @enabled_rules.setter - def enabled_rules(self, value: Set[str]) -> None: - """ - Set the rules to explicitly enable during text checking. + def enabled_rules(self, value: set[str]) -> None: + """Set the rules to explicitly enable during text checking. :param value: A set of rule IDs to enable. - :type value: Set[str] + :type value: set[str] """ self._enabled_rules = value @property - def disabled_categories(self) -> Set[str]: - """ - Get the set of disabled rule categories. + def disabled_categories(self) -> set[str]: + """Get the set of disabled rule categories. :return: A set of disabled category names. - :rtype: Set[str] + :rtype: set[str] """ return self._disabled_categories @disabled_categories.setter - def disabled_categories(self, value: Set[str]) -> None: - """ - Set the rule categories to disable during text checking. + def disabled_categories(self, value: set[str]) -> None: + """Set the rule categories to disable during text checking. :param value: A set of category names to disable. - :type value: Set[str] + :type value: set[str] """ self._disabled_categories = value @property - def enabled_categories(self) -> Set[str]: - """ - Get the set of enabled rule categories. + def enabled_categories(self) -> set[str]: + """Get the set of enabled rule categories. :return: A set of enabled category names. - :rtype: Set[str] + :rtype: set[str] """ return self._enabled_categories @enabled_categories.setter - def enabled_categories(self, value: Set[str]) -> None: - """ - Set the rule categories to explicitly enable during text checking. + def enabled_categories(self, value: set[str]) -> None: + """Set the rule categories to explicitly enable during text checking. :param value: A set of category names to enable. - :type value: Set[str] + :type value: set[str] """ self._enabled_categories = value @property def enabled_rules_only(self) -> bool: - """ - Get whether only enabled rules should be used. + """Get whether only enabled rules should be used. :return: True if using only enabled rules, False otherwise. :rtype: bool @@ -469,8 +513,7 @@ def enabled_rules_only(self) -> bool: @enabled_rules_only.setter def enabled_rules_only(self, value: bool) -> None: - """ - Set whether to use only explicitly enabled rules. + """Set whether to use only explicitly enabled rules. When set to True, only rules in enabled_rules will be applied. @@ -480,32 +523,29 @@ def enabled_rules_only(self, value: bool) -> None: self._enabled_rules_only = value @property - def preferred_variants(self) -> Set[str]: - """ - Get the set of preferred language variants. + def preferred_variants(self) -> set[str]: + """Get the set of preferred language variants. :return: A set of preferred variant codes. - :rtype: Set[str] + :rtype: set[str] """ return self._preferred_variants @preferred_variants.setter - def preferred_variants(self, value: Set[str]) -> None: - """ - Set the preferred language variants. + def preferred_variants(self, value: set[str]) -> None: + """Set the preferred language variants. - Preferred variants influence which suggestions LanguageTool provides - (e.g., en-US vs en-GB). + Preferred variants influence which suggestions LanguageTool provides (e.g., en- + US vs en-GB). :param value: A set of preferred variant codes. - :type value: Set[str] + :type value: set[str] """ self._preferred_variants = value @property def picky(self) -> bool: - """ - Get whether picky mode is enabled. + """Get whether picky mode is enabled. :return: True if picky mode is enabled, False otherwise. :rtype: bool @@ -514,10 +554,10 @@ def picky(self) -> bool: @picky.setter def picky(self, value: bool) -> None: - """ - Set whether to enable picky mode for stricter checking. + """Set whether to enable picky mode for stricter checking. - Picky mode enables additional style rules that may be too strict for casual writing. + Picky mode enables additional style rules that may be too strict for casual + writing. :param value: True to enable picky mode, False for standard checking. :type value: bool @@ -525,22 +565,20 @@ def picky(self, value: bool) -> None: self._picky = value @property - def config(self) -> Optional[LanguageToolConfig]: - """ - Get the server configuration. + def config(self) -> LanguageToolConfig | None: + """Get the server configuration. - This property is read-only as the configuration is set during initialization - and cannot be changed while the server is running. + This property is read-only as the configuration is set during initialization and + cannot be changed while the server is running. :return: The configuration object if set, otherwise None. - :rtype: Optional[LanguageToolConfig] + :rtype: LanguageToolConfig | None """ return self._config @property def language_tool_download_version(self) -> str: - """ - Get the LanguageTool version to download. + """Get the LanguageTool version to download. This property is read-only as the version is determined during initialization and the server cannot be re-downloaded with a different version at runtime. @@ -552,11 +590,10 @@ def language_tool_download_version(self) -> str: @property def url(self) -> str: - """ - Get the LanguageTool server URL. + """Get the LanguageTool server URL. - This property is read-only as the URL is determined during initialization - and cannot be changed while the server is running. + This property is read-only as the URL is determined during initialization and + cannot be changed while the server is running. :return: The server URL (e.g., 'http://localhost:8081/v2/'). :rtype: str @@ -565,11 +602,10 @@ def url(self) -> str: @property def is_remote(self) -> bool: - """ - Get whether using a remote LanguageTool server. + """Get whether using a remote LanguageTool server. - This property is read-only as the remote status is determined during initialization - and cannot be changed while the server is running. + This property is read-only as the remote status is determined during + initialization and cannot be changed while the server is running. :return: True if using a remote server, False if using a local server. :rtype: bool @@ -578,11 +614,10 @@ def is_remote(self) -> bool: @property def host(self) -> str: - """ - Get the local server host address. + """Get the local server host address. - This property is read-only as the host address is determined during initialization - and cannot be changed while the server is running. + This property is read-only as the host address is determined during + initialization and cannot be changed while the server is running. :return: The host address (e.g., '127.0.0.1'). :rtype: str @@ -591,47 +626,65 @@ def host(self) -> str: @property def port(self) -> int: - """ - Get the local server port number. + """Get the local server port number. - This property is read-only as the port number is determined during initialization - and cannot be changed while the server is running. + This property is read-only as the port number is determined during + initialization and cannot be changed while the server is running. :return: The port number (e.g., 8081). :rtype: int """ return self._port - def check(self, text: str) -> List[Match]: - """ - Checks the given text for language issues using the LanguageTool server. + def check(self, text: str) -> list[Match]: + """Check the given text for language issues using the LanguageTool server. :param text: The text to be checked for language issues. :type text: str :return: A list of Match objects representing the issues found in the text. - :rtype: List[Match] + :rtype: list[Match] + :raises ServerError: If no response is received from the LanguageTool server or + if the response shape is invalid. + :raises ValueError: If the configured mother tongue tag is unsupported. + :raises RateLimitError: If the public LanguageTool API rate limit is exceeded. + :raises LanguageToolError: If the server query fails. """ url = urllib.parse.urljoin(self._url, "check") logger.debug("Sending text to LanguageTool server at %s", url) - response = self._query_server(url, self._create_params(text), method="post") + raw_response = self._query_server(url, self._create_params(text), method="post") + if raw_response is None: + err = "No response received from the LanguageTool server." + raise ServerError(err) + if not is_check_response(raw_response): + err = ( + f"Invalid response received from the " + f"LanguageTool server: {raw_response}" + ) + raise ServerError(err) + response = cast("CheckResponse", raw_response) matches = response["matches"] return [Match(match, text) for match in matches] def check_matching_regions( - self, text: str, pattern: str, flags: int = 0 - ) -> List[Match]: - """ - Check only the parts of the text that match a regex pattern. + self, + text: str, + pattern: str, + flags: int = 0, + ) -> list[Match]: + """Check only the parts of the text that match a regex pattern. + The returned Match objects can be applied to the original text with :func:`language_tool_python.utils.correct`. :param text: The full text. :param pattern: Regular expression defining the regions to check :param flags: Regex flags (re.IGNORECASE, re.MULTILINE, etc.) - :return: List of Match with offsets adjusted to the original text - :rtype: List[Match] + :return: A list of Match objects with offsets adjusted to the original text. + :rtype: list[Match] + :raises ValueError: If the configured mother tongue tag is unsupported. + :raises RateLimitError: If the public LanguageTool API rate limit is exceeded. + :raises LanguageToolError: If the server query fails. """ - # Find all matching regions matches_iter = re.finditer(pattern, text, flags) regions = [(m.start(), m.group()) for m in matches_iter] @@ -639,7 +692,7 @@ def check_matching_regions( if not regions: return [] # No regions to check - all_matches: List[Match] = [] + all_matches: list[Match] = [] for start_offset, region_text in regions: region_matches = self.check(region_text) @@ -652,14 +705,13 @@ def check_matching_regions( return sorted(all_matches, key=lambda m: m.offset) - def _create_params(self, text: str) -> Dict[str, str]: - """ - Create a dictionary of parameters for the language tool server request. + def _create_params(self, text: str) -> dict[str, str]: + """Create a dictionary of parameters for the language tool server request. :param text: The text to be checked. :type text: str :return: A dictionary containing the parameters for the request. - :rtype: Dict[str, str] + :rtype: dict[str, str] The dictionary may contain the following keys: - 'language': The language code. @@ -668,9 +720,12 @@ def _create_params(self, text: str) -> Dict[str, str]: - 'disabledRules': A comma-separated list of disabled rules, if specified. - 'enabledRules': A comma-separated list of enabled rules, if specified. - 'enabledOnly': 'true' if only enabled rules should be used. - - 'disabledCategories': A comma-separated list of disabled categories, if specified. - - 'enabledCategories': A comma-separated list of enabled categories, if specified. - - 'preferredVariants': A comma-separated list of preferred language variants, if specified. + - 'disabledCategories': A comma-separated list of disabled categories, + if specified. + - 'enabledCategories': A comma-separated list of enabled categories, + if specified. + - 'preferredVariants': A comma-separated list of preferred language variants, + if specified. - 'level': 'picky' if picky mode is enabled. """ params = {"language": str(self._language), "text": text} @@ -695,39 +750,42 @@ def _create_params(self, text: str) -> Dict[str, str]: return params def correct(self, text: str) -> str: - """ - Corrects the given text by applying language tool suggestions. Applies only the first suggestion for each issue. + """Corrects the given text by applying language tool suggestions. + Applies only the first suggestion for each issue. :param text: The text to be corrected. :type text: str :return: The corrected text. :rtype: str + :raises ValueError: If the configured mother tongue tag is unsupported. + :raises RateLimitError: If the public LanguageTool API rate limit is exceeded. + :raises LanguageToolError: If the server query fails. """ return correct(text, self.check(text)) def enable_spellchecking(self) -> None: - """ - Enable spellchecking by removing spell checking categories from the disabled categories set. - This method updates the ``disabled_categories`` attribute by removing any categories that are - related to spell checking, which are defined in the ``_SPELL_CHECKING_CATEGORIES`` class constant. + """Enable spellchecking by removing spellcheck category exclusions. + + This method updates the ``disabled_categories`` attribute by removing any + categories that are related to spell checking, which are defined in the + ``_SPELL_CHECKING_CATEGORIES`` class constant. """ self._disabled_categories.difference_update(self._SPELL_CHECKING_CATEGORIES) def disable_spellchecking(self) -> None: - """ - Disable spellchecking by updating the disabled categories with spell checking categories. - """ + """Disable spellchecking by adding spellcheck categories to exclusions.""" self._disabled_categories.update(self._SPELL_CHECKING_CATEGORIES) def _get_valid_spelling_file_path(self) -> Path: - """ - Retrieve the valid file path for the spelling file. + """Retrieve the valid file path for the spelling file. + This function constructs the file path for the spelling file used by the - language tool. It checks if the file exists at the constructed path and - raises a FileNotFoundError if the file is not found. + language tool. It checks if the file exists at the constructed path and raises a + FileNotFoundError if the file is not found. :raises FileNotFoundError: If the spelling file does not exist at the - constructed path. + constructed path. + :raises PathError: If the local LanguageTool instance is not initialized. :return: The valid file path for the spelling file. :rtype: Path """ @@ -741,10 +799,12 @@ def _get_valid_spelling_file_path(self) -> Path: ].lower() # if language is "en-US", we want "en" if language == "auto": - # Default to English if auto is selected, as the spelling file is needed for new spellings - # The new spellings will only be taken into account if the server detects the language as English + # Default to English if auto is selected, as the spelling file is needed + # for new spellings + # The new spellings will only be taken into account if the server detects + # the language as English logger.debug( - "Language is set to 'auto'. Defaulting to 'en' for spelling file path." + "Language is set to 'auto'. Defaulting to 'en' for spelling file path.", ) language = "en" @@ -767,20 +827,19 @@ def _get_valid_spelling_file_path(self) -> Path: return spelling_file_path def _register_spellings(self) -> None: - """ - Registers new spellings by adding them to the spelling file. - This method reads the existing spellings from the spelling file, - filters out the new spellings that are already present, and appends - the remaining new spellings to the file. If the DEBUG_MODE is enabled, - it prints a message indicating the file where the new spellings were registered. - """ + """Register new spellings by adding them to the spelling file. + This method reads the existing spellings from the spelling file, filters out the + new spellings that are already present, and appends the remaining new spellings + to the file. If the DEBUG_MODE is enabled, it prints a message indicating the + file where the new spellings were registered. + """ if self._new_spellings is None: return spelling_file_path = self._get_valid_spelling_file_path() logger.debug("Registering new spellings at %s", spelling_file_path) - with open(spelling_file_path, "r+", encoding="utf-8") as spellings_file: + with spelling_file_path.open("r+", encoding="utf-8") as spellings_file: existing_spellings = {line.strip() for line in spellings_file.readlines()} new_spellings = [ word for word in self._new_spellings if word not in existing_spellings @@ -792,8 +851,8 @@ def _register_spellings(self) -> None: spellings_file.write("\n".join(new_spellings)) def _unregister_spellings(self) -> None: - """ - Unregister new spellings from the spelling file. + """Unregister new spellings from the spelling file. + This method reads the current spellings from the spelling file, removes any spellings that are present in the ``_new_spellings`` attribute, and writes the updated list back to the file. @@ -804,7 +863,7 @@ def _unregister_spellings(self) -> None: spelling_file_path = self._get_valid_spelling_file_path() logger.debug("Unregistering new spellings at %s", spelling_file_path) - with open(spelling_file_path, "r", encoding="utf-8") as spellings_file: + with spelling_file_path.open("r", encoding="utf-8") as spellings_file: lines = spellings_file.readlines() updated_lines = [ @@ -813,46 +872,67 @@ def _unregister_spellings(self) -> None: if updated_lines and updated_lines[-1].endswith("\n"): updated_lines[-1] = updated_lines[-1].strip() - with open( - spelling_file_path, - "w", - encoding="utf-8", - newline="\n", + with spelling_file_path.open( + "w", encoding="utf-8", newline="\n" ) as spellings_file: spellings_file.writelines(updated_lines) - def _get_languages(self) -> Set[str]: - """ - Retrieve the set of supported languages from the server. + def _get_languages(self) -> set[str]: + """Retrieve the set of supported languages from the server. + This method starts the server if it is not already running, constructs the URL for querying the supported languages, and sends a request to the server. It then - processes the server's response to extract the language codes and adds them to - a set. The special code "auto" is also added to the set before returning it. + processes the server's response to extract the language codes and adds them to a + set. The special code "auto" is also added to the set before returning it. :return: A set of language codes supported by the server. - :rtype: Set[str] + :rtype: set[str] + :raises ServerError: If no response is received or if the response shape is + invalid. + :raises LanguageToolError: If the server query fails. """ self._start_server_if_needed() url = urllib.parse.urljoin(self._url, "languages") - languages: Set[str] = set() - for e in self._query_server(url, num_tries=1): - languages.add(e.get("code")) - languages.add(e.get("longCode")) + languages: set[str] = set() + raw_languages_response = self._query_server(url, num_tries=1) + if raw_languages_response is None: + err = ( + "No response received from the LanguageTool server when " + "fetching languages." + ) + raise ServerError(err) + if isinstance(raw_languages_response, list): + for raw_lang in raw_languages_response: + if not is_language_info(raw_lang): + err = ( + "Unexpected response format when fetching languages from the " + "LanguageTool server." + ) + raise ServerError(err) + lang = cast("LanguageInfo", raw_lang) + languages.add(lang["code"]) + languages.add(lang["longCode"]) + else: + err = ( + "Unexpected response format when fetching languages from the " + "LanguageTool server." + ) + raise ServerError(err) languages.add("auto") return languages def _start_server_if_needed(self) -> None: - """ - Starts the server if it is not already running and if it is not a remote server. - This method checks if the server is alive and if it is not a remote server. - If the server is not alive and it is not remote, it starts the server on a free port. + """Start the server unless it is already running or remote. + + This method checks if the server is alive and if it is not a remote server. If + the server is not alive and it is not remote, it starts the server on a free + port. """ if not self._server_is_alive() and self._remote is False: self._start_server_on_free_port() def _update_remote_server_config(self, url: str) -> None: - """ - Update the configuration to use a remote server. + """Update the configuration to use a remote server. :param url: The URL of the remote server. :type url: str @@ -863,24 +943,27 @@ def _update_remote_server_config(self, url: str) -> None: def _query_server( self, url: str, - params: Optional[Dict[str, str]] = None, + params: dict[str, str] | None = None, num_tries: int = 2, method: Literal["get", "post"] = "get", - ) -> Any: - """ - Query the server with the given URL and parameters. + ) -> object | None: + """Query the server with the given URL and parameters. :param url: The URL to query. :type url: str :param params: The parameters to include in the query, defaults to None. - :type params: Optional[Dict[str, str]], optional - :param num_tries: The number of times to retry the query in case of failure, defaults to 2. + :type params: dict[str, str] | None, optional + :param num_tries: The number of times to retry the query in case of failure, + defaults to 2. :type num_tries: int, optional - :param method: HTTP method to use for the request. ``post`` sends params in the request body. + :param method: HTTP method to use for the request. ``post`` sends params in the + request body. :type method: Literal["get", "post"] :return: The JSON response from the server. - :rtype: Any - :raises LanguageToolError: If the server returns an invalid JSON response or if the query fails after the specified number of retries. + :rtype: object | None + :raises RateLimitError: If the public LanguageTool API rate limit is exceeded. + :raises LanguageToolError: If the server returns an invalid JSON response or if + the query fails after the specified number of retries. """ logger.debug("_query_server url: %s", url) for n in range(num_tries): @@ -901,7 +984,7 @@ def _query_server( ) with response_context as response: try: - return response.json() + data: object = response.json() except json.decoder.JSONDecodeError as e: logger.debug( "URL %s returned invalid JSON response: %s", @@ -909,14 +992,16 @@ def _query_server( e, ) logger.debug("Status code: %s", response.status_code) - if response.status_code == 426: + if response.status_code == HTTP_STATUS_RATE_LIMIT: err = ( "You have exceeded the rate limit for the free " "LanguageTool API. Please try again later." ) raise RateLimitError(err) from e raise LanguageToolError(response.content.decode()) from e - except (IOError, http.client.HTTPException) as e: + else: + return data + except (OSError, http.client.HTTPException) as e: # noqa: PERF203 # it is intentional to catch exceptions in a loop, to retry the request in case of transient errors if self._remote is False: self._terminate_server() self._start_local_server() @@ -926,13 +1011,14 @@ def _query_server( return None def _start_server_on_free_port(self) -> None: - """ - Attempt to start the server on a free port within the specified range. - This method continuously tries to start the local server on the current host and port. - If the port is already in use, it increments the port number and tries again until a free port is found - or the maximum port number is reached. + """Attempt to start the server on a free port within the specified range. - :raises ServerError: If the server cannot be started and the maximum port number is reached. + This method continuously tries to start the local server on the current host and + port. If the port is already in use, it increments the port number and tries + again until a free port is found or the maximum port number is reached. + + :raises ServerError: If the server cannot be started and the maximum port number + is reached. """ while True: try: @@ -947,29 +1033,36 @@ def _start_server_on_free_port(self) -> None: raise def _start_local_server(self) -> None: - """ - Start the local LanguageTool server. - This method starts a local instance of the LanguageTool server. If the - LanguageTool is not already downloaded, it will download the specified - version. It handles the server initialization, including setting up - the server command and managing the server process. + """Start the local LanguageTool server. - :raises PathError: If the path to LanguageTool cannot be found. - :raises ServerError: If the server fails to start or exits early. + This method starts a local instance of the LanguageTool server. If the + LanguageTool is not already downloaded, it will download the specified version. + It handles the server initialization, including setting up the server command + and managing the server process. + + :raises ModuleNotFoundError: If no Java installation is detected. + :raises SystemError: If the detected Java version is incompatible. + :raises TimeoutError: If the LanguageTool download request times out. + :raises JavaError: If the Java executable cannot be found. + :raises PathError: If the path to LanguageTool cannot be found or the download + cannot be prepared safely. + :raises ServerError: If the local server process does not become ready. """ # Before starting local server, download language tool if needed. self._local_language_tool = LocalLanguageTool.from_version_name( - self._language_tool_download_version + self._language_tool_download_version, ) self._local_language_tool.download() try: if self._port: logger.info( - "language_tool_python initializing with port: %s", self._port + "language_tool_python initializing with port: %s", + self._port, ) server_cmd = self._local_language_tool.get_server_cmd( - self._port, self._config + self._port, + self._config, ) except PathError as e: err = ( @@ -985,28 +1078,23 @@ def _start_local_server(self) -> None: text=True, startupinfo=startupinfo, ) - global RUNNING_SERVER_PROCESSES RUNNING_SERVER_PROCESSES.append(self._server) self._wait_for_server_ready() - if not self._server: - err = "Failed to start LanguageTool server." - raise ServerError(err) - def _wait_for_server_ready(self, timeout: int = 15) -> None: - """ - Wait for the LanguageTool server to become ready and responsive. + """Wait for the LanguageTool server to become ready and responsive. + This method polls the server's ``/healthcheck`` endpoint until it responds successfully or until the timeout is reached. It also monitors the server process to detect early exits. :param timeout: Maximum time in seconds to wait for the server to become ready. - Defaults to 15 seconds. + Defaults to 15 seconds. :type timeout: int - :raises ServerError: If the server process exits early with a non-zero code, - or if the server does not become ready within the specified - timeout period or if the server process is not initialized. + :raises ServerError: If the server process exits early with a non-zero code, or + if the server does not become ready within the specified timeout period or + if the server process is not initialized. """ if self._server is None: err = "Server process is not initialized." @@ -1045,8 +1133,8 @@ def _wait_for_server_ready(self, timeout: int = 15) -> None: raise ServerError(err) def _server_is_alive(self) -> bool: - """ - Check if the server is alive. + """Check if the server is alive. + This method checks if the server instance exists and is currently running. :return: True if the server is alive (exists and running), False otherwise. @@ -1055,8 +1143,8 @@ def _server_is_alive(self) -> bool: return bool(self._server and self._server.poll() is None) def _terminate_server(self) -> None: - """ - Terminates the server process. + """Terminates the server process. + This method performs the following steps: 1. Attempts to terminate the server process gracefully. 2. Closes associated file descriptor (stdin). @@ -1074,31 +1162,51 @@ def _terminate_server(self) -> None: class LanguageToolPublicAPI(LanguageTool): - """ - A class to interact with the public LanguageTool API. - This class extends the ``LanguageTool`` class and initializes it with the - remote server set to the public LanguageTool API endpoint. - - :param args: Positional arguments passed to the parent class initializer. - :type args: Any - :param kwargs: Keyword arguments passed to the parent class initializer. - :type kwargs: Any + """A class to interact with the public LanguageTool API. + + This class extends the ``LanguageTool`` class and initializes it with the remote + server set to the public LanguageTool API endpoint. + + :param language: The language code to use for checking text (e.g., 'en-US'). + :type language: str | None + :param mother_tongue: The mother tongue language code, if specified (e.g., 'en'). + :type mother_tongue: str | None + :param new_spellings: A list of new spellings to register, if any. + :type new_spellings: list[str] | None + :param new_spellings_persist: Whether to persist new spellings across sessions. + :type new_spellings_persist: bool + :param proxies: A dictionary of proxies to use for requests to the remote server. + :type proxies: dict[str, str] | None + :raises ValueError: If the language tag is unsupported. + :raises PathError: If custom spellings are requested for the remote public API. + :raises LanguageToolError: If the public API cannot be queried while initializing. """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - """ - Initialize the server with the given arguments. - """ - kwargs.setdefault("remote_server", "https://languagetool.org/api/") - super().__init__(*args, **kwargs) + def __init__( + self, + language: str | None = None, + mother_tongue: str | None = None, + new_spellings: list[str] | None = None, + new_spellings_persist: bool = True, + proxies: dict[str, str] | None = None, + ) -> None: + """Initialize the LanguageToolPublicAPI server.""" + super().__init__( + language=language, + mother_tongue=mother_tongue, + remote_server="https://languagetool.org/api/", + new_spellings=new_spellings, + new_spellings_persist=new_spellings_persist, + proxies=proxies, + ) @atexit.register def terminate_server() -> None: - """ - Terminates all running server processes. - This function iterates over the list of running server processes and - forcefully kills each process by its PID. + """Terminates all running server processes. + + This function iterates over the list of running server processes and forcefully + kills each process by its PID. """ if RUNNING_SERVER_PROCESSES: logger.info( diff --git a/language_tool_python/utils.py b/language_tool_python/utils.py index e1caf85..8b3c1ef 100644 --- a/language_tool_python/utils.py +++ b/language_tool_python/utils.py @@ -1,5 +1,7 @@ """Utility functions for the LanguageTool library.""" +from __future__ import annotations + import contextlib import locale import logging @@ -10,15 +12,17 @@ from enum import Enum from pathlib import Path from shutil import which -from typing import Any, List, Optional, Tuple +from typing import TYPE_CHECKING, Protocol, runtime_checkable import psutil from packaging import version from ._deprecated import deprecated -from .config_file import LanguageToolConfig from .exceptions import JavaError, PathError -from .match import Match + +if TYPE_CHECKING: + from .config_file import LanguageToolConfig + from .match import Match logger = logging.getLogger(__name__) @@ -33,10 +37,6 @@ # Directory containing the LanguageTool jar file: LTP_JAR_DIR_PATH_ENV_VAR = "LTP_JAR_DIR_PATH" -# https://mail.python.org/pipermail/python-dev/2011-July/112551.html - -startupinfo: Optional[Any] = None - if os.name == "nt": # Gets STARTUPINFO dynamically to avoid issues on non-Windows platforms startupinfo_cls = getattr(subprocess, "STARTUPINFO", None) @@ -48,10 +48,10 @@ def parse_url(url_str: str) -> str: - """ - Parse the given URL string and ensure it has a scheme. - If the input URL string does not contain 'http', 'http://' is prepended to it. - The function then parses the URL and returns its canonical form. + """Parse the given URL string and ensure it has a scheme. + + If the input URL string does not contain 'http', 'http://' is prepended to it. The + function then parses the URL and returns its canonical form. :param url_str: The URL string to be parsed. :type url_str: str @@ -65,8 +65,7 @@ def parse_url(url_str: str) -> str: def get_env_int(env_var: str, default: int) -> int: - """ - Read a positive integer from the environment. + """Read a positive integer from the environment. :param env_var: Environment variable name. :type env_var: str @@ -95,8 +94,7 @@ def get_env_int(env_var: str, default: int) -> int: def get_env_float(env_var: str, default: float) -> float: - """ - Read a positive float from the environment. + """Read a positive float from the environment. :param env_var: Environment variable name. :type env_var: str @@ -125,40 +123,42 @@ def get_env_float(env_var: str, default: float) -> float: class TextStatus(Enum): + """Status classification for matches.""" + CORRECT = "correct" FAULTY = "faulty" GARBAGE = "garbage" -def classify_matches(matches: List[Match]) -> TextStatus: - """ - Classify the matches (result of a check on a text) into one of three categories: - CORRECT, FAULTY, or GARBAGE. +def classify_matches(matches: list[Match]) -> TextStatus: + """Classify matches as CORRECT, FAULTY, or GARBAGE. + This function checks the status of the matches and returns a corresponding ``TextStatus`` value. :param matches: A list of Match objects to be classified. - :type matches: List[Match] + :type matches: list[Match] :return: The classification of the matches as a ``TextStatus`` value. :rtype: TextStatus """ if not len(matches): return TextStatus.CORRECT matches = [match for match in matches if match.replacements] - if not len(matches): + if not matches: return TextStatus.GARBAGE return TextStatus.FAULTY -def correct(text: str, matches: List[Match]) -> str: - """ - Corrects the given text based on the provided matches. +def correct(text: str, matches: list[Match]) -> str: + """Corrects the given text based on the provided matches. + Only the first replacement for each match is applied to the text. :param text: The original text to be corrected. :type text: str - :param matches: A list of Match objects that contain the positions and replacements for errors in the text. - :type matches: List[Match] + :param matches: A list of Match objects that contain the positions and replacements + for errors in the text. + :type matches: list[Match] :return: The corrected text. :rtype: str """ @@ -182,12 +182,13 @@ def correct(text: str, matches: List[Match]) -> str: def get_language_tool_download_path() -> Path: - """ - Get the download path for LanguageTool. - This function retrieves the download path for LanguageTool from the environment variable - specified by ``LTP_PATH_ENV_VAR``. If the environment variable is not set, it defaults to - a path in the user's home directory under ``.cache/language_tool_python``. - The function ensures that the directory exists before returning it. + """Get the download path for LanguageTool. + + This function retrieves the download path for LanguageTool from the environment + variable specified by ``LTP_PATH_ENV_VAR``. If the environment variable is not set, + it defaults to a path in the user's home directory under + ``.cache/language_tool_python``. The function ensures that the directory exists + before returning it. :return: The download path for LanguageTool. :rtype: Path @@ -203,19 +204,22 @@ def get_language_tool_download_path() -> Path: @deprecated( - "This function is no longer used internally and will be removed in 4.0.\nReplace its usage by an inline alternative.", + ( + "This function is no longer used internally and will be removed in 4.0.\n" + "Replace its usage by an inline alternative." + ), stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] -def find_existing_language_tool_downloads(download_folder: Path) -> List[Path]: - """ - Find existing LanguageTool downloads in the specified folder. +def find_existing_language_tool_downloads(download_folder: Path) -> list[Path]: + """Find existing LanguageTool downloads in the specified folder. + This function searches for directories in the given download folder that match the pattern 'LanguageTool*' and returns a list of their paths. :param download_folder: The folder where LanguageTool downloads are stored. :type download_folder: Path :return: A list of paths to the existing LanguageTool download directories. - :rtype: List[Path] + :rtype: list[Path] .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. @@ -228,8 +232,7 @@ def find_existing_language_tool_downloads(download_folder: Path) -> List[Path]: stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] def _extract_version(path: Path) -> version.Version: - """ - Extract the version number from a LanguageTool directory path. + """Extract the version number from a LanguageTool directory path. This function parses the directory name to extract the version information from LanguageTool installation folders that follow the naming convention @@ -245,7 +248,8 @@ def _extract_version(path: Path) -> version.Version: This function is no longer used internally and will be removed in 4.0. """ if not path.name.startswith("LanguageTool-"): - raise ValueError(f"Invalid LanguageTool folder name: {path.name}") + err = f"Invalid LanguageTool folder name: {path.name}" + raise ValueError(err) # Handle LanguageTool- prefix version_str = path.name.removeprefix("LanguageTool-") # Handle both -SNAPSHOT and -snapshot suffixes @@ -254,25 +258,30 @@ def _extract_version(path: Path) -> version.Version: @deprecated( - "This function is no longer used internally and will be removed in 4.0.\nUse instead language_tool_python.download_lt.LocalLanguageTool.get_latest_installed_version.", + ( + "This function is no longer used internally and will be removed in 4.0.\n" + "Use instead " + "language_tool_python.download_lt.LocalLanguageTool." + "get_latest_installed_version." + ), stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] def get_language_tool_directory() -> Path: - """ - Get the directory path of the LanguageTool installation. + """Get the directory path of the LanguageTool installation. + This function checks the download folder for LanguageTool installations, verifies that the folder exists and is a directory, and returns the path to the latest version of LanguageTool found in the directory. :raises NotADirectoryError: If the download folder path is not a valid directory. - :raises FileNotFoundError: If no LanguageTool installation is found in the download folder. + :raises FileNotFoundError: If no LanguageTool installation is found in the download + folder. :return: The path to the latest version of LanguageTool found in the directory. :rtype: Path .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. """ - download_folder = get_language_tool_download_path() if not download_folder.is_dir(): err = f"LanguageTool directory path is not a valid directory {download_folder}." @@ -293,22 +302,29 @@ def get_language_tool_directory() -> Path: @deprecated( - "This function is no longer used internally and will be removed in 4.0.\nUse instead language_tool_python.download_lt.LocalLanguageTool.get_server_cmd.", + ( + "This function is no longer used internally and will be removed in 4.0.\n" + "Use instead language_tool_python.download_lt.LocalLanguageTool.get_server_cmd." + ), stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] def get_server_cmd( - port: Optional[int] = None, - config: Optional[LanguageToolConfig] = None, -) -> List[str]: - """ - Generate the command to start the LanguageTool HTTP server. - - :param port: Optional; The port number on which the server should run. If not provided, the default port will be used. - :type port: Optional[int] - :param config: Optional; The configuration for the LanguageTool server. If not provided, default configuration will be used. - :type config: Optional[LanguageToolConfig] + port: int | None = None, + config: LanguageToolConfig | None = None, +) -> list[str]: + """Generate the command to start the LanguageTool HTTP server. + + :param port: Optional; The port number on which the server should run. If not + provided, the default port will be used. + :type port: int | None + :param config: Optional; The configuration for the LanguageTool server. If not + provided, default configuration will be used. + :type config: LanguageToolConfig | None :return: A list of command line arguments to start the LanguageTool HTTP server. - :rtype: List[str] + :rtype: list[str] + :raises JavaError: If the Java executable cannot be found. + :raises PathError: If the LanguageTool JAR file cannot be found in the specified + directory. .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. @@ -335,22 +351,23 @@ def get_server_cmd( "This function is no longer used internally and will be removed in 4.0.", stacklevel=2, ) # type: ignore[untyped-decorator, unused-ignore] -def get_jar_info() -> Tuple[Path, Path]: - """ - Retrieve the path to the Java executable and the LanguageTool JAR file. +def get_jar_info() -> tuple[Path, Path]: + """Retrieve the path to the Java executable and the LanguageTool JAR file. + This function searches for the Java executable in the system's PATH and locates the LanguageTool JAR file either in a directory specified by an environment variable or in a default download directory. :raises JavaError: If the Java executable cannot be found. - :raises PathError: If the LanguageTool JAR file cannot be found in the specified directory. - :return: A tuple containing the path to the Java executable and the path to the LanguageTool JAR file. - :rtype: Tuple[Path, Path] + :raises PathError: If the LanguageTool JAR file cannot be found in the specified + directory. + :return: A tuple containing the path to the Java executable and the path to the + LanguageTool JAR file. + :rtype: tuple[Path, Path] .. deprecated:: 3.3.0 This function is no longer used internally and will be removed in 4.0. """ - java_path_str = which("java") if not java_path_str: err = "can't find Java" @@ -380,12 +397,12 @@ def get_jar_info() -> Tuple[Path, Path]: def get_locale_language() -> str: - """ - Get the current locale language. - This function retrieves the current locale language setting of the system. - It first attempts to get the locale using ``locale.getlocale()``. If that fails, - it falls back to using ``locale.getdefaultlocale()``. If both methods fail to - provide a valid language code, it returns a default failsafe language code. + """Get the current locale language. + + This function retrieves the current locale language setting of the system. It first + attempts to get the locale using ``locale.getlocale()``. If that fails, it falls + back to using ``locale.getdefaultlocale()``. If both methods fail to provide a valid + language code, it returns a default failsafe language code. :return: The language code of the current locale. :rtype: str @@ -395,18 +412,21 @@ def get_locale_language() -> str: def kill_process_force( *, - pid: Optional[int] = None, - proc: Optional[psutil.Process] = None, + pid: int | None = None, + proc: psutil.Process | None = None, ) -> None: - """ - Forcefully kills a process and all its child processes. - This function attempts to kill a process specified either by its PID or by a psutil.Process object. - If the process has any child processes, they will be killed first. - - :param pid: The process ID of the process to be killed. Either ``pid`` or ``proc`` must be provided. - :type pid: Optional[int] - :param proc: A psutil.Process object representing the process to be killed. Either ``pid`` or ``proc`` must be provided. - :type proc: Optional[psutil.Process] + """Terminate a process and all of its child processes forcefully. + + This function attempts to kill a process specified either by its PID or by a + psutil.Process object. If the process has any child processes, they will be killed + first. + + :param pid: The process ID of the process to be killed. Either ``pid`` or ``proc`` + must be provided. + :type pid: int | None + :param proc: A psutil.Process object representing the process to be killed. Either + ``pid`` or ``proc`` must be provided. + :type proc: psutil.Process | None :raises ValueError: If neither ``pid`` nor ``proc`` is provided. """ if not any([pid, proc]): @@ -424,3 +444,38 @@ def kill_process_force( child.kill() with contextlib.suppress(psutil.NoSuchProcess): proc.kill() + + +@runtime_checkable +class SupportsBool(Protocol): + """Protocol for types that can be converted to a boolean value.""" + + def __bool__(self) -> bool: + """Define the interface for types that can be evaluated in a boolean context.""" + ... + + +@deprecated( + "This protocol is no longer used internally and will be removed in 4.0.", + stacklevel=2, +) # type: ignore[untyped-decorator, unused-ignore] +@runtime_checkable +class SupportsInt(Protocol): + """Protocol for types that can be converted to an integer value.""" + + def __int__(self) -> int: + """Define the interface for types that can be converted to an integer.""" + ... + + +@deprecated( + "This protocol is no longer used internally and will be removed in 4.0.", + stacklevel=2, +) # type: ignore[untyped-decorator, unused-ignore] +@runtime_checkable +class SupportsFloat(Protocol): + """Protocol for types that can be converted to a float value.""" + + def __float__(self) -> float: + """Define the interface for types that can be converted to a float.""" + ... diff --git a/make.bat b/make.bat index 958baf6..d2f485c 100644 --- a/make.bat +++ b/make.bat @@ -25,18 +25,18 @@ uv sync --all-groups --locked exit /b %errorlevel% :format -uv run --group quality --locked ruff format language_tool_python tests +uv run --group quality --locked ruff format exit /b %errorlevel% :fix -uv run --group quality --locked ruff check --fix language_tool_python tests +uv run --group quality --locked ruff check --fix exit /b %errorlevel% :ruff-check -uv run --group quality --locked ruff check language_tool_python tests +uv run --group quality --locked ruff check if errorlevel 1 exit /b %errorlevel% -uv run --group quality --locked ruff format --check language_tool_python tests +uv run --group quality --locked ruff format --check exit /b %errorlevel% :mypy-check diff --git a/pyproject.toml b/pyproject.toml index 6c41fb8..9147740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,9 +60,9 @@ types = [ quality = [ # Keep in sync mypy version with .pre-commit-config.yaml - "mypy==2.0.0; python_version >= '3.10'", # mypy checks must be run with Python >= 3.10 + "mypy==2.1.0; python_version >= '3.10'", # mypy checks must be run with Python >= 3.10 # Keep in sync ruff version with .pre-commit-config.yaml - "ruff==0.15.12", + "ruff==0.15.16", ] @@ -76,18 +76,94 @@ build-backend = "uv_build" [tool.uv.build-backend] module-root = "" # uv expects the code to be in a "src/" directory by default, but our code is in the the current dir +[tool.ruff] +line-length = 88 +include = ["language_tool_python/*.py", "tests/*.py"] + [tool.ruff.lint] -select = ["F", "W", "I", "B", "SIM", "RET", "Q", "N", "S", "BLE"] +select = [ + "A", # Built-in shadowing and variable shadowing checks + "ANN", # Type annotation presence and quality checks + "ARG", # Unused function and method arguments + "ASYNC", # Asyncio and asynchronous programming issues + "B", # Likely bugs and dangerous patterns (flake8-bugbear) + "BLE", # Blind exception handling (e.g. bare except) + "COM", # Trailing commas consistency + "C4", # Unnecessary or suboptimal collection constructions + "C90", # McCabe complexity checks + "D", # Docstring conventions and style (pydocstyle) + "DTZ", # Naive datetime usage and timezone issues + "E", # pycodestyle errors (PEP 8 violations) + "EM", # Exception message formatting issues + "ERA", # Commented-out dead code detection + "F", # Pyflakes logical and static analysis errors + "FA", # Future-compatible typing annotations + "FBT", # Boolean positional argument "traps" + "FIX", # TODO, FIXME, XXX comment detection + "FLY", # Prefer f-strings over str.format() + "FURB", # Refactoring and modernization suggestions (refurb) + "G", # Logging format and logging call issues + "I", # Import sorting and organization (isort) + "ICN", # Import alias naming conventions + "INP", # Missing __init__.py package checks + "N", # PEP 8 naming conventions (pep8-naming) + "PERF", # Performance anti-patterns + "PGH", # Pygrep-hooks rules and miscellaneous checks + "PIE", # Unnecessary or non-pythonic code patterns + "PL", # Pylint-derived checks + "PT", # Pytest style and correctness checks + "PTH", # Prefer pathlib over os/path utilities + "Q", # Quote style consistency + "RET", # Return statement consistency and simplification + "RSE", # Raise statement improvements + "RUF", # Ruff-specific rules + "S", # Security issues and insecure code patterns (bandit) + "SLF", # Access to private members + "SLOT", # __slots__ usage checks + "SIM", # Code simplification opportunities + "TC", # Type-checking block optimizations + "TCH", # Move typing-only imports into TYPE_CHECKING blocks + "TID", # Import restriction and tidiness rules + "TRY", # try/except structure improvements + "T10", # Debugger breakpoint detection + "T20", # print/pprint statement detection + "UP", # Python syntax and API modernization (pyupgrade) + "W", # pycodestyle warnings + "YTT", # Unsafe or outdated sys.version checks +] +ignore = [ + "COM812", # Missing trailing comma in confict with formatter + "D203", # Has to choose between D203 and D211 (No blank line before class docstring) + "D213", # Has to choose between D213 and D212 (Multi-line docstring should start at the first line) + "FBT001", # Positional boolean arguments. Fix these in next breaking change release + "FBT002", # Default positional boolean arguments. Fix these in next breaking change release + "TRY301", # Raise an exception in a try block that is caught by the except block. There is a workaround for this in the codebase +] [tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["S101"] +"tests/*.py" = [ + "S101", # Need to use assert statements in tests + "SLF001" # Need to use private members of the library for testing +] +"language_tool_python/__main__.py" = ["T201"] # Allow usage of print in the CLI entry point [tool.mypy] files = ["language_tool_python", "tests"] -strict = true -warn_unused_ignores = true -warn_redundant_casts = true -warn_unreachable = true +# disallow_any_expr = true +disallow_any_generics = true +disallow_any_unimported = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +extra_checks = true no_implicit_optional = true -show_error_codes = true +no_implicit_reexport = true pretty = true +show_error_codes = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true diff --git a/tests/test_api_public.py b/tests/test_api_public.py index 73ebd75..1788741 100644 --- a/tests/test_api_public.py +++ b/tests/test_api_public.py @@ -2,26 +2,68 @@ import pytest +import language_tool_python from language_tool_python.exceptions import RateLimitError def test_remote_es() -> None: - """ - Test the public API with Spanish language text. - This test verifies that the LanguageToolPublicAPI correctly identifies - various errors in a Spanish text sample. + """Test the public API with Spanish language text. + + This test verifies that the LanguageToolPublicAPI correctly identifies various + errors in a Spanish text sample. :raises AssertionError: If the detected matches do not match the expected output. """ - import language_tool_python - try: with language_tool_python.LanguageToolPublicAPI("es") as tool: - es_text = "Escriba un texto aquí. LanguageTool le ayudará a afrentar algunas dificultades propias de la escritura. Se a hecho un esfuerzo para detectar errores tipográficos, ortograficos y incluso gramaticales. También algunos errores de estilo, a grosso modo." + es_text = ( + "Escriba un texto aquí. LanguageTool le ayudará a afrentar " + "algunas dificultades propias de la escritura. Se a hecho un esfuerzo " + "para detectar errores tipográficos, ortograficos y incluso " + "gramaticales. También algunos errores de estilo, a grosso modo." + ) matches = tool.check(es_text) assert ( str(matches) - == """[Match({'rule_id': 'AFRENTAR_DIFICULTADES', 'message': 'Confusión entre «afrontar» y «afrentar».', 'replacements': ['afrontar'], 'offset_in_context': 43, 'context': '...n texto aquí. LanguageTool le ayudará a afrentar algunas dificultades propias de la escr...', 'offset': 49, 'error_length': 8, 'category': 'INCORRECT_EXPRESSIONS', 'rule_issue_type': 'grammar', 'sentence': 'LanguageTool le ayudará a afrentar algunas dificultades propias de la escritura.'}), Match({'rule_id': 'PRON_HABER_PARTICIPIO', 'message': 'El v. ‘haber’ se escribe con hache.', 'replacements': ['ha'], 'offset_in_context': 43, 'context': '...ificultades propias de la escritura. Se a hecho un esfuerzo para detectar errores...', 'offset': 107, 'error_length': 1, 'category': 'MISSPELLING', 'rule_issue_type': 'misspelling', 'sentence': 'Se a hecho un esfuerzo para detectar errores tipográficos, ortograficos y incluso gramaticales.'}), Match({'rule_id': 'MORFOLOGIK_RULE_ES', 'message': 'Se ha encontrado un posible error ortográfico.', 'replacements': ['ortográficos', 'ortográficas', 'ortográfico', 'orográficos', 'ortografiaos', 'ortografíeos'], 'offset_in_context': 43, 'context': '...rzo para detectar errores tipográficos, ortograficos y incluso gramaticales. También algunos...', 'offset': 163, 'error_length': 12, 'category': 'TYPOS', 'rule_issue_type': 'misspelling', 'sentence': 'Se a hecho un esfuerzo para detectar errores tipográficos, ortograficos y incluso gramaticales.'}), Match({'rule_id': 'Y_E_O_U', 'message': 'Cuando precede a palabras que comienzan por ‘i’, la conjunción ‘y’ se transforma en ‘e’.', 'replacements': ['e'], 'offset_in_context': 43, 'context': '...ctar errores tipográficos, ortograficos y incluso gramaticales. También algunos e...', 'offset': 176, 'error_length': 1, 'category': 'GRAMMAR', 'rule_issue_type': 'grammar', 'sentence': 'Se a hecho un esfuerzo para detectar errores tipográficos, ortograficos y incluso gramaticales.'}), Match({'rule_id': 'GROSSO_MODO', 'message': 'Esta expresión latina se usa sin preposición.', 'replacements': ['grosso modo'], 'offset_in_context': 43, 'context': '...les. También algunos errores de estilo, a grosso modo.', 'offset': 235, 'error_length': 13, 'category': 'GRAMMAR', 'rule_issue_type': 'grammar', 'sentence': 'También algunos errores de estilo, a grosso modo.'})]""" + == """[Match({'rule_id': 'AFRENTAR_DIFICULTADES', 'message': 'Confusión + entre «afrontar» y «afrentar».', 'replacements': ['afrontar'], + 'offset_in_context': 43, 'context': '...n texto aquí. + + LanguageTool le ayudará a afrentar algunas dificultades propias de la + escr...', 'offset': 49, 'error_length': 8, 'category': + 'INCORRECT_EXPRESSIONS', 'rule_issue_type': 'grammar', 'sentence': + 'LanguageTool le ayudará a afrentar algunas dificultades propias de + la escritura.'}), Match({'rule_id': 'PRON_HABER_PARTICIPIO', + 'message': 'El v. \u2018haber\u2019 se escribe con hache.', + 'replacements': ['ha'], 'offset_in_context': 43, 'context': + '...ificultades propias de la escritura. Se a hecho un esfuerzo para + detectar errores...', 'offset': 107, 'error_length': 1, 'category': + 'MISSPELLING', 'rule_issue_type': 'misspelling', 'sentence': 'Se a + hecho un esfuerzo para detectar errores tipográficos, ortograficos y + incluso gramaticales.'}), Match({'rule_id': 'MORFOLOGIK_RULE_ES', + 'message': 'Se ha encontrado un posible error ortográfico.', + 'replacements': ['ortográficos', 'ortográficas', 'ortográfico', + 'orográficos', 'ortografiaos', 'ortografíeos'], 'offset_in_context': + 43, 'context': '...rzo para detectar errores tipográficos, + ortograficos y incluso gramaticales. También algunos...', 'offset': + 163, 'error_length': 12, 'category': 'TYPOS', 'rule_issue_type': + 'misspelling', 'sentence': 'Se a hecho un esfuerzo para detectar + errores tipográficos, ortograficos y incluso gramaticales.'}), + Match({'rule_id': 'Y_E_O_U', 'message': 'Cuando precede a palabras + que comienzan por \u2018i\u2019, la conjunción \u2018y\u2019 se + transforma en \u2018e\u2019.', 'replacements': ['e'], + 'offset_in_context': 43, 'context': '...ctar errores tipográficos, + ortograficos y incluso gramaticales. También algunos e...', 'offset': + 176, 'error_length': 1, 'category': 'GRAMMAR', 'rule_issue_type': + 'grammar', 'sentence': 'Se a hecho un esfuerzo para detectar errores + tipográficos, ortograficos y incluso gramaticales.'}), + Match({'rule_id': 'GROSSO_MODO', 'message': 'Esta expresión latina se + usa sin preposición.', 'replacements': ['grosso modo'], + 'offset_in_context': 43, 'context': '...les. También algunos errores + de estilo, a grosso modo.', 'offset': 235, 'error_length': 13, + 'category': 'GRAMMAR', 'rule_issue_type': 'grammar', 'sentence': + 'También algunos errores de estilo, a grosso modo.'})] + """ ) except RateLimitError: pytest.skip("Rate limit exceeded for public API.") diff --git a/tests/test_cli.py b/tests/test_cli.py index 9c7330e..6f1e71b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,13 +2,16 @@ import io import sys -from typing import Generator, List, Tuple +from collections.abc import Generator import pytest +import language_tool_python +from language_tool_python.__main__ import main + @pytest.mark.parametrize( - "argv, stdin, should_succeed", + ("argv", "stdin", "should_succeed"), [ (["-l", "en-US", "-"], "This is okay.\n", True), (["-l", "en-US", "-"], "This is noot okay.\n", False), @@ -38,20 +41,19 @@ ], ) def test_cli_exit_codes( - argv: List[str], + argv: list[str], stdin: str, should_succeed: bool, - capsys: pytest.CaptureFixture[str], ) -> None: - """ - Test the CLI exit codes with various command-line arguments and inputs. - This test verifies that the command-line interface returns the correct exit codes - (0 for success, non-zero for errors) based on different configurations and input texts. + """Test the CLI exit codes with various command-line arguments and inputs. + + This test verifies that the command-line interface returns the correct exit codes (0 + for success, non-zero for errors) based on different configurations and input texts. :param argv: Command-line arguments to pass to the CLI. :param stdin: Input text to be checked for errors. - :param should_succeed: Expected outcome (True if no errors expected, False otherwise). - :param capsys: Pytest fixture for capturing stdout/stderr. + :param should_succeed: Expected outcome (True if no errors expected, False + otherwise). :raises AssertionError: If the exit code does not match the expected outcome. """ code = main_with_stdin(argv, stdin) @@ -63,17 +65,15 @@ def test_cli_exit_codes( @pytest.fixture(scope="module") -def remote_server() -> Generator[Tuple[str, int], None, None]: - """ - Fixture that provides a remote LanguageTool server for testing. +def remote_server() -> Generator[tuple[str, int], None, None]: + """Fixture that provides a remote LanguageTool server for testing. + This fixture initializes a LanguageTool instance and yields its host and port, ensuring proper cleanup after all tests in the module complete. :return: A tuple containing the server host and port (host, port). :rtype: Generator[Tuple[str, int], None, None] """ - import language_tool_python - tool = language_tool_python.LanguageTool("en-US") host = tool._host port = tool._port @@ -84,9 +84,9 @@ def remote_server() -> Generator[Tuple[str, int], None, None]: tool.close() -def test_cli_remote_ok(remote_server: Tuple[str, int]) -> None: - """ - Test the CLI with a remote server using valid input text. +def test_cli_remote_ok(remote_server: tuple[str, int]) -> None: + """Test the CLI with a remote server using valid input text. + This test verifies that the CLI correctly communicates with a remote LanguageTool server and returns a success exit code when the input text contains no errors. @@ -110,9 +110,9 @@ def test_cli_remote_ok(remote_server: Tuple[str, int]) -> None: assert code == 0 -def test_cli_remote_error(remote_server: Tuple[str, int]) -> None: - """ - Test the CLI with a remote server using text containing errors. +def test_cli_remote_error(remote_server: tuple[str, int]) -> None: + """Test the CLI with a remote server using text containing errors. + This test verifies that the CLI correctly communicates with a remote LanguageTool server and returns a non-zero exit code when the input text contains errors. @@ -136,20 +136,18 @@ def test_cli_remote_error(remote_server: Tuple[str, int]) -> None: assert code != 0 -def main_with_stdin(argv: List[str], stdin: str) -> int: - """ - Helper function to execute the main CLI with simulated stdin input. +def main_with_stdin(argv: list[str], stdin: str) -> int: + """Execute the main CLI with simulated stdin input. + This utility function temporarily replaces sys.stdin with a StringIO object - containing the provided input, executes the main CLI function, and then - restores the original stdin. + containing the provided input, executes the main CLI function, and then restores the + original stdin. :param argv: Command-line arguments to pass to the main function. :param stdin: Input text to simulate as stdin. :return: Exit code returned by the main function. :rtype: int """ - from language_tool_python.__main__ import main - old_stdin = sys.stdin sys.stdin = io.StringIO(stdin) try: diff --git a/tests/test_config.py b/tests/test_config.py index de8f4bb..087d5d3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,21 +5,21 @@ import pytest -from language_tool_python.config_file import LanguageToolConfig +import language_tool_python +from language_tool_python.config_file import ConfigValue, LanguageToolConfig from language_tool_python.exceptions import LanguageToolError def test_langtool_languages() -> None: - """ - Test that LanguageTool supports the expected set of languages. - This test verifies that the LanguageTool instance correctly identifies - and returns all expected supported languages, including various regional - variants and language codes. + """Test that LanguageTool supports the expected set of languages. - :raises AssertionError: If the supported languages do not include all expected languages. - """ - import language_tool_python + This test verifies that the LanguageTool instance correctly identifies and returns + all expected supported languages, including various regional variants and language + codes. + :raises AssertionError: If the supported languages do not include all expected + languages. + """ with language_tool_python.LanguageTool("en-US") as tool: assert tool._get_languages().issuperset( { @@ -104,24 +104,25 @@ def test_langtool_languages() -> None: def test_config_text_length() -> None: - """ - Test the maxTextLength configuration parameter. + """Test the maxTextLength configuration parameter. + This test verifies that LanguageTool correctly enforces the maximum text length - limit specified in the configuration, raising an error for texts exceeding the - limit while successfully checking texts within the limit. + limit specified in the configuration, raising an error for texts exceeding the limit + while successfully checking texts within the limit. - :raises AssertionError: If the tool does not raise an error for text exceeding the limit - or fails to check text within the limit. + :raises AssertionError: If the tool does not raise an error for text exceeding the + limit or fails to check text within the limit. """ - import language_tool_python - with language_tool_python.LanguageTool( "en-US", config={"maxTextLength": 12}, ) as tool: - # With this config file, checking text with >12 characters should raise an error. + # With this config file, checking text with >12 characters should raise an error error_msg = re.escape( - "Error: Your text exceeds the limit of 12 characters (it's 27 characters). Please submit a shorter text.", + ( + "Error: Your text exceeds the limit of 12 characters (it's 27 " + "characters). Please submit a shorter text." + ), ) with pytest.raises(LanguageToolError, match=error_msg): tool.check("Hello darkness my old frend") @@ -131,17 +132,16 @@ def test_config_text_length() -> None: def test_config_caching() -> None: - """ - Test the caching configuration parameters. - This test verifies that LanguageTool's caching mechanism (cacheSize and pipelineCaching) - significantly improves performance when checking the same text multiple times. The test - measures the time difference between the first and second checks to ensure caching - provides a substantial speedup. + """Test the caching configuration parameters. - :raises AssertionError: If caching does not provide the expected performance improvement. - """ - import language_tool_python + This test verifies that LanguageTool's caching mechanism (cacheSize and + pipelineCaching) significantly improves performance when checking the same text + multiple times. The test measures the time difference between the first and second + checks to ensure caching provides a substantial speedup. + :raises AssertionError: If caching does not provide the expected performance + improvement. + """ with language_tool_python.LanguageTool( "en-US", config={"cacheSize": 1000, "pipelineCaching": True}, @@ -153,24 +153,23 @@ def test_config_caching() -> None: tool.check(s) t3 = time.time() - # This is a silly test that says: caching should speed up a grammar-checking by a factor - # of speed_factor when checking the same sentence twice. It theoretically could be very flaky. + # This is a silly test that says: caching should speed up a grammar-checking + # by a factor of speed_factor when checking the same sentence twice. It + # theoretically could be very flaky. # But in practice I've observed speedup of around 250x (6.76s to 0.028s). speedup_factor = 10.0 assert (t2 - t1) / speedup_factor > (t3 - t2) def test_disabled_rule_in_config() -> None: - """ - Test the disabledRuleIds configuration parameter. - This test verifies that LanguageTool correctly disables specific grammar rules - when specified in the configuration. The test checks text that would normally - trigger the disabled rule and confirms that no matches are returned. + """Test the disabledRuleIds configuration parameter. + + This test verifies that LanguageTool correctly disables specific grammar rules when + specified in the configuration. The test checks text that would normally trigger the + disabled rule and confirms that no matches are returned. :raises AssertionError: If the disabled rule still produces matches. """ - import language_tool_python - grammar_tool_config = {"disabledRuleIds": ["MORFOLOGIK_RULE_EN_US"]} with language_tool_python.LanguageTool("en-US", config=grammar_tool_config) as tool: text = "He realised that the organization was in jeopardy." @@ -187,10 +186,8 @@ def test_disabled_rule_in_config() -> None: {"lang-en": "custom-word\nrequestLimit=0"}, ], ) -def test_config_rejects_line_break_injection(config: dict[str, object]) -> None: - """ - Test that config serialization cannot be escaped with CR/LF characters. - """ +def test_config_rejects_line_break_injection(config: dict[str, ConfigValue]) -> None: + """Test that config serialization cannot be escaped with CR/LF characters.""" with pytest.raises(ValueError, match="cannot contain line breaks"): LanguageToolConfig(config) @@ -204,9 +201,9 @@ def test_config_rejects_line_break_injection(config: dict[str, object]) -> None: {"lang-en": "custom-word\\"}, ], ) -def test_config_rejects_odd_trailing_backslashes(config: dict[str, object]) -> None: - """ - Test that config serialization cannot escape the line ending with a backslash. - """ +def test_config_rejects_odd_trailing_backslashes( + config: dict[str, ConfigValue], +) -> None: + """Test that config serialization cannot escape the line ending with a backslash.""" with pytest.raises(ValueError, match="odd number of backslashes"): LanguageToolConfig(config) diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index 51cf15c..13ec36a 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -1,10 +1,15 @@ """Tests for the deprecated decorator.""" +from __future__ import annotations + import warnings -from typing import Dict, Optional, Tuple from language_tool_python._deprecated import deprecated +EXPECTED_CUSTOM_WARNING_RESULT = 42 +EXPECTED_FUNCTION_SUM = 5 +EXPECTED_WARNING_COUNT = 3 + def test_deprecated_emits_warning() -> None: """Test that the deprecated decorator emits a DeprecationWarning.""" @@ -37,7 +42,7 @@ def old_function() -> int: assert len(w) == 1 assert issubclass(w[0].category, UserWarning) assert "This is a user warning" in str(w[0].message) - assert result == 42 + assert result == EXPECTED_CUSTOM_WARNING_RESULT def test_deprecated_preserves_function_signature() -> None: @@ -52,7 +57,7 @@ def my_function(x: int, y: int) -> int: assert my_function.__name__ == "my_function" assert my_function.__doc__ is not None assert "Add two numbers" in my_function.__doc__ - assert my_function(2, 3) == 5 + assert my_function(2, 3) == EXPECTED_FUNCTION_SUM def test_deprecated_with_multiple_calls() -> None: @@ -68,7 +73,7 @@ def func() -> str: func() func() - assert len(w) == 3 + assert len(w) == EXPECTED_WARNING_COUNT assert all(issubclass(warning.category, DeprecationWarning) for warning in w) @@ -77,8 +82,12 @@ def test_deprecated_with_args_and_kwargs() -> None: @deprecated("This function is obsolete") # type: ignore[untyped-decorator, unused-ignore] def complex_function( - a: int, b: int, *args: int, c: Optional[int] = None, **kwargs: int - ) -> Tuple[int, int, Tuple[int, ...], Optional[int], Dict[str, int]]: + a: int, + b: int, + *args: int, + c: int | None = None, + **kwargs: int, + ) -> tuple[int, int, tuple[int, ...], int | None, dict[str, int]]: return (a, b, args, c, kwargs) with warnings.catch_warnings(record=True) as w: diff --git a/tests/test_download.py b/tests/test_download.py index 80afb0c..04b283a 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -10,11 +10,13 @@ import zipfile from collections.abc import Iterator from contextlib import contextmanager +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import MagicMock, patch import pytest +import language_tool_python from language_tool_python import download_lt from language_tool_python.download_lt import ( LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, @@ -24,26 +26,32 @@ ) from language_tool_python.exceptions import LanguageToolError, PathError +EXPECTED_DOWNLOAD_BYTES_OVERRIDE = 123 + class MockDownloadResponse: - """ - Minimal requests.Response replacement for download tests. - """ + """Minimal requests.Response replacement for download tests.""" def __init__(self, payload: bytes, status_code: int = 200) -> None: + """Initialize the mock response with the given payload and status code.""" self.payload = payload self.status_code = status_code self.headers = {"Content-Length": str(len(payload))} def iter_content(self, chunk_size: int) -> Iterator[bytes]: + """Simulate streaming content by yielding chunks of the payload. + + :param chunk_size: The size of each chunk to yield. + :type chunk_size: int + :return: An iterator that yields chunks of the payload. + :rtype: Iterator[bytes] + """ for index in range(0, len(self.payload), chunk_size): yield self.payload[index : index + chunk_size] def make_zip_payload(files: dict[str, bytes]) -> bytes: - """ - Create an in-memory ZIP payload for download tests. - """ + """Create an in-memory ZIP payload for download tests.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zip_file: for filename, payload in files.items(): @@ -53,9 +61,7 @@ def make_zip_payload(files: dict[str, bytes]) -> bytes: @contextmanager def workspace_temp_dir() -> Iterator[Path]: - """ - Create a temporary directory inside the repository workspace. - """ + """Create a temporary directory inside the repository workspace.""" root = Path.cwd() / ".test_download_tmp" path = root / uuid.uuid4().hex path.mkdir(parents=True) @@ -68,52 +74,50 @@ def workspace_temp_dir() -> Iterator[Path]: def test_install_inexistent_version() -> None: - """ - Test that attempting to download a non-existent LanguageTool version raises an error. - This test verifies that the tool correctly handles invalid version numbers by raising - a LanguageToolError when trying to initialize with a version that does not exist. + """Test errors when downloading a non-existent LanguageTool version. + + This test verifies that the tool correctly handles invalid version numbers by + raising a LanguageToolError when trying to initialize with a version that does not + exist. :raises AssertionError: If LanguageToolError is not raised for an invalid version. """ - import language_tool_python - with pytest.raises(LanguageToolError): language_tool_python.LanguageTool(language_tool_download_version="0.0") def test_install_too_old_version() -> None: - """ - Test that attempting to download a too-old LanguageTool version raises an error. - This test verifies that the tool correctly handles versions that are no longer supported - by raising a PathError when trying to initialize with an outdated version. + """Test that attempting to download a too-old LanguageTool version raises an error. + + This test verifies that the tool correctly handles versions that are no longer + supported by raising a PathError when trying to initialize with an outdated version. :raises AssertionError: If PathError is not raised for a too-old version. """ - import language_tool_python - with pytest.raises(PathError): language_tool_python.LanguageTool(language_tool_download_version="3.9") def test_inexistent_language() -> None: - """ - Test that creating a LanguageTag with an invalid language code raises an error. - This test verifies that the LanguageTag constructor correctly validates language codes - and raises a ValueError when given a language code that is not supported. + """Test that creating a LanguageTag with an invalid language code raises an error. + + This test verifies that the LanguageTag constructor correctly validates language + codes and raises a ValueError when given a language code that is not supported. :raises AssertionError: If ValueError is not raised for an invalid language code. """ - import language_tool_python - - with language_tool_python.LanguageTool("en-US") as tool, pytest.raises(ValueError): + with ( + language_tool_python.LanguageTool("en-US") as tool, + pytest.raises(ValueError, match="unsupported language"), + ): language_tool_python.LanguageTag("xx-XX", tool._get_languages()) def test_http_get_403_forbidden() -> None: - """ - Test that http_get raises PathError when receiving a 403 Forbidden status code. - This test verifies that the function correctly handles forbidden access errors - when attempting to download files. + """Test that http_get raises PathError when receiving a 403 Forbidden status code. + + This test verifies that the function correctly handles forbidden access errors when + attempting to download files. :raises AssertionError: If PathError is not raised for a 403 status code. """ @@ -121,20 +125,21 @@ def test_http_get_403_forbidden() -> None: mock_response.status_code = 403 mock_response.headers = {} + out_file = io.BytesIO() + local_language_tool = LocalLanguageTool.from_version_name() with ( patch( - "language_tool_python.download_lt.requests.get", return_value=mock_response + "language_tool_python.download_lt.requests.get", + return_value=mock_response, ), pytest.raises(PathError, match="Access forbidden to URL"), ): - out_file = io.BytesIO() - local_language_tool = LocalLanguageTool.from_version_name() local_language_tool._get_remote_zip(out_file) def test_http_get_other_error_codes() -> None: - """ - Test that http_get raises PathError for various HTTP error codes (other than 404 and 403). + """Test PathError handling for unexpected HTTP status codes. + This test verifies that the function correctly handles different HTTP error codes like 500 (Internal Server Error), 503 (Service Unavailable), etc. @@ -147,6 +152,8 @@ def test_http_get_other_error_codes() -> None: mock_response.status_code = error_code mock_response.headers = {} + out_file = io.BytesIO() + local_language_tool = LocalLanguageTool.from_version_name() with ( patch( "language_tool_python.download_lt.requests.get", @@ -154,19 +161,15 @@ def test_http_get_other_error_codes() -> None: ), pytest.raises(PathError, match=f"Failed to download.*{error_code}"), ): - out_file = io.BytesIO() - local_language_tool = LocalLanguageTool.from_version_name() local_language_tool._get_remote_zip(out_file) def test_http_get_rejects_oversized_content_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that oversized ZIP downloads are rejected before streaming. - """ + """Test that oversized ZIP downloads are rejected before streaming.""" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) response = MockDownloadResponse(payload) response.headers["Content-Length"] = "2" @@ -185,15 +188,18 @@ def test_http_get_rejects_oversized_content_length( def test_max_download_bytes_uses_env_override( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that the download size limit can be configured from the environment. - """ + """Test that the download size limit can be configured from the environment.""" try: with monkeypatch.context() as env: - env.setenv(LTP_MAX_DOWNLOAD_BYTES_ENV_VAR, "123") + env.setenv( + LTP_MAX_DOWNLOAD_BYTES_ENV_VAR, str(EXPECTED_DOWNLOAD_BYTES_OVERRIDE) + ) reloaded_download_lt = importlib.reload(download_lt) - assert reloaded_download_lt.MAX_DOWNLOAD_BYTES == 123 + assert ( + reloaded_download_lt.MAX_DOWNLOAD_BYTES + == EXPECTED_DOWNLOAD_BYTES_OVERRIDE + ) finally: importlib.reload(download_lt) @@ -201,11 +207,9 @@ def test_max_download_bytes_uses_env_override( def test_http_get_rejects_oversized_stream( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that downloads are still size-limited when Content-Length is missing. - """ + """Test that downloads are still size-limited when Content-Length is missing.""" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) response = MockDownloadResponse(payload) response.headers = {} @@ -224,11 +228,9 @@ def test_http_get_rejects_oversized_stream( def test_http_get_rejects_oversized_stream_with_small_content_length( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that a lying Content-Length cannot bypass the streamed download limit. - """ + """Test that a lying Content-Length cannot bypass the streamed download limit.""" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) response = MockDownloadResponse(payload) response.headers["Content-Length"] = "1" @@ -248,9 +250,7 @@ def test_http_get_rejects_oversized_stream_with_small_content_length( def test_http_get_rejects_invalid_content_length( content_length: str, ) -> None: - """ - Test that invalid Content-Length values are rejected before streaming. - """ + """Test that invalid Content-Length values are rejected before streaming.""" response = MockDownloadResponse(b"") response.headers["Content-Length"] = content_length @@ -267,9 +267,7 @@ def test_http_get_rejects_invalid_content_length( def test_latest_snapshot_uses_latest_download_url_and_current_date( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that latest remains a snapshot alias installed under the current date. - """ + """Test that latest remains a snapshot alias installed under the current date.""" monkeypatch.setattr( download_lt, "BASE_URL_SNAPSHOT", @@ -292,9 +290,7 @@ def test_release_download_url_uses_new_release_base_from_6_7( release_version: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that releases 6.7 and newer include the version in the base URL. - """ + """Test that releases 6.7 and newer include the version in the base URL.""" monkeypatch.setattr( download_lt, "BASE_URL_NEW_RELEASES", @@ -314,9 +310,7 @@ def test_release_download_url_uses_new_release_base_from_6_7( def test_release_download_url_keeps_main_release_base_for_6_6( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that release 6.6 keeps using the versioned filename. - """ + """Test that release 6.6 keeps using the versioned filename.""" monkeypatch.setattr( download_lt, "BASE_URL_RELEASE", @@ -334,9 +328,7 @@ def test_release_download_url_keeps_main_release_base_for_6_6( def test_release_download_url_keeps_main_release_base_before_6_7( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that earlier 6.x releases keep using the versioned filename. - """ + """Test that earlier 6.x releases keep using the versioned filename.""" monkeypatch.setattr( download_lt, "BASE_URL_RELEASE", @@ -354,9 +346,7 @@ def test_release_download_url_keeps_main_release_base_before_6_7( def test_release_download_url_keeps_archive_base_before_6_0( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that older supported releases keep using the archive base URL. - """ + """Test that older supported releases keep using the archive base URL.""" monkeypatch.setattr( download_lt, "BASE_URL_ARCHIVE", @@ -374,11 +364,9 @@ def test_release_download_url_keeps_archive_base_before_6_0( def test_http_get_verifies_configured_sha256( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that downloads are accepted when the configured SHA-256 matches. - """ + """Test that downloads are accepted when the configured SHA-256 matches.""" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) local_language_tool = LocalLanguageTool.from_version_name() suffix = ( @@ -399,16 +387,14 @@ def test_http_get_verifies_configured_sha256( out_file = io.BytesIO() with local_language_tool._get_remote_zip(out_file) as zip_file: assert zip_file.namelist() == [ - "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar" + "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar", ] def test_http_get_uses_integrity_manifest_sha256( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that bundled integrity.toml checksums are used when no env var is set. - """ + """Test that bundled integrity.toml checksums are used when no env var is set.""" payload = make_zip_payload({"LanguageTool-4.0/languagetool-server.jar": b"jar"}) local_language_tool = LocalLanguageTool.from_version_name("4.0") monkeypatch.delenv(LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, raising=False) @@ -428,11 +414,9 @@ def test_http_get_uses_integrity_manifest_sha256( def test_http_get_rejects_sha256_mismatch( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that downloads are rejected when the configured SHA-256 mismatches. - """ + """Test that downloads are rejected when the configured SHA-256 mismatches.""" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) local_language_tool = LocalLanguageTool.from_version_name() suffix = ( @@ -446,6 +430,7 @@ def test_http_get_rejects_sha256_mismatch( "0" * 64, ) + out_file = io.BytesIO() with ( patch( "language_tool_python.download_lt.requests.get", @@ -453,46 +438,41 @@ def test_http_get_rejects_sha256_mismatch( ), pytest.raises(PathError, match="checksum mismatch"), ): - out_file = io.BytesIO() local_language_tool._get_remote_zip(out_file) def test_http_get_bypass_skips_sha256_verification( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that the bypass disables SHA-256 verification. - """ + """Test that the bypass disables SHA-256 verification.""" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) local_language_tool = LocalLanguageTool.from_version_name() monkeypatch.setenv(LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR, "true") monkeypatch.setenv(LTP_DOWNLOAD_SHA256_ENV_VAR, "0" * 64) + out_file = io.BytesIO() with ( patch( "language_tool_python.download_lt.requests.get", return_value=MockDownloadResponse(payload), ), pytest.warns(RuntimeWarning, match="Verified downloads are bypassed"), + local_language_tool._get_remote_zip(out_file) as zip_file, ): - out_file = io.BytesIO() - with local_language_tool._get_remote_zip(out_file) as zip_file: - assert zip_file.namelist() == [ - "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar" - ] + assert zip_file.namelist() == [ + "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar", + ] def test_snapshot_download_renames_archive_root_to_requested_date( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that date-pinned snapshots are installed under the requested date name. - """ + """Test that date-pinned snapshots are installed under the requested date name.""" requested_snapshot = "20240101" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) local_language_tool = LocalLanguageTool.from_version_name(requested_snapshot) monkeypatch.setattr(download_lt, "confirm_java_compatibility", lambda _: None) @@ -505,7 +485,9 @@ def test_snapshot_download_renames_archive_root_to_requested_date( ), ): monkeypatch.setattr( - download_lt, "get_language_tool_download_path", lambda: temp_dir + download_lt, + "get_language_tool_download_path", + lambda: temp_dir, ) local_language_tool.download() @@ -523,12 +505,10 @@ def test_snapshot_download_renames_archive_root_to_requested_date( def test_latest_snapshot_download_renames_archive_root_to_current_date( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that latest snapshots are installed under the current date name. - """ + """Test that latest snapshots are installed under the current date name.""" current_snapshot_date = "20240514" payload = make_zip_payload( - {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"} + {"LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar"}, ) with patch("language_tool_python.download_lt.datetime") as datetime_mock: datetime_mock.now.return_value.strftime.return_value = current_snapshot_date @@ -543,7 +523,9 @@ def test_latest_snapshot_download_renames_archive_root_to_current_date( ), ): monkeypatch.setattr( - download_lt, "get_language_tool_download_path", lambda: temp_dir + download_lt, + "get_language_tool_download_path", + lambda: temp_dir, ) local_language_tool.download() @@ -559,18 +541,18 @@ def test_latest_snapshot_download_renames_archive_root_to_current_date( def test_install_oldest_supported_version() -> None: - """ - Test that downloading the oldest supported LanguageTool version works correctly. - This test verifies that the tool can successfully download and initialize - with the oldest version that is still supported. + """Test that downloading the oldest supported LanguageTool version works correctly. - :raises AssertionError: If the tool fails to initialize with the oldest supported version. - """ - import language_tool_python + This test verifies that the tool can successfully download and initialize with the + oldest version that is still supported. + :raises AssertionError: If the tool fails to initialize with the oldest supported + version. + """ try: with language_tool_python.LanguageTool( - "en-US", language_tool_download_version="4.0" + "en-US", + language_tool_download_version="4.0", ) as tool: assert tool is not None except LanguageToolError: @@ -578,26 +560,25 @@ def test_install_oldest_supported_version() -> None: def test_install_snapshot_version() -> None: - """ - Test that downloading the snapshot version of LanguageTool works correctly. - This test verifies that the tool can successfully download and initialize - with the snapshot of yesterday. + """Test that downloading the snapshot version of LanguageTool works correctly. + + This test verifies that the tool can successfully download and initialize with the + snapshot of yesterday. :raises AssertionError: If the tool fails to initialize with the snapshot version. """ - from datetime import datetime, timedelta - - import language_tool_python - try: with language_tool_python.LanguageTool( "en-US", language_tool_download_version=( - (datetime.now() - timedelta(days=3)).strftime("%Y%m%d") + (datetime.now(timezone.utc) - timedelta(days=3)).strftime("%Y%m%d") ), ) as tool: assert tool is not None except LanguageToolError: pytest.skip( - "Failed to download or initialize the snapshot version. This may be due to a missing snapshot for the expected date." + ( + "Failed to download or initialize the snapshot version. This may be " + "due to a missing snapshot for the expected date." + ), ) diff --git a/tests/test_match.py b/tests/test_match.py index 364ca16..448ccf1 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -1,24 +1,28 @@ """Tests for the Match functionality of LanguageTool.""" -from typing import Any, Dict, List +from typing import cast + +import language_tool_python + +EXPECTED_MATCH_COUNT = 2 +EXPECTED_CORRECTED_MATCH_COUNT = 4 def test_langtool_load() -> None: - """ - Test the basic functionality of LanguageTool and Match object attributes. - This test verifies that LanguageTool correctly identifies grammar and spelling errors - in a given text and that Match objects contain all expected attributes with correct values, - including rule_id, message, replacements, offsets, category, and sentence information. + """Test the basic functionality of LanguageTool and Match object attributes. + + This test verifies that LanguageTool correctly identifies grammar and spelling + errors in a given text and that Match objects contain all expected attributes with + correct values, including rule_id, message, replacements, offsets, category, and + sentence information. :raises AssertionError: If the detected matches do not match the expected output or - if Match object attributes are incorrect. + if Match object attributes are incorrect. """ - import language_tool_python - with language_tool_python.LanguageTool("en-US") as tool: matches = tool.check("ain't nothin but a thang") - expected_matches: List[Dict[str, Any]] = [ + expected_matches: list[dict[str, str | list[str] | int]] = [ { "rule_id": "UPPERCASE_SENTENCE_START", "message": "This sentence does not start with an uppercase letter.", @@ -94,46 +98,49 @@ def test_langtool_load() -> None: for key in [ "replacements", ]: - assert set(expected_matches[match_i][key]) == set(getattr(match, key)) + expected_replacements = cast( + "list[str]", + expected_matches[match_i][key], + ) + assert set(expected_replacements) == set(getattr(match, key)) def test_match() -> None: - """ - Test the string representation of Match objects. + """Test the string representation of Match objects. + This test verifies that Match objects can be correctly converted to a human-readable string format that includes the offset, length, rule ID, error message, suggestions, and contextual visualization of the error location. - :raises AssertionError: If the Match string representation does not match the expected format. + :raises AssertionError: If the Match string representation does not match the + expected format. """ - import language_tool_python - with language_tool_python.LanguageTool("en-US") as tool: - text = "A sentence with a error in the Hitchhiker’s Guide tot he Galaxy" + text = "A sentence with a error in the Hitchhiker\u2019s Guide tot he Galaxy" matches = tool.check(text) - assert len(matches) == 2 + assert len(matches) == EXPECTED_MATCH_COUNT assert str(matches[0]) == ( "Offset 16, length 1, Rule ID: EN_A_VS_AN\n" - "Message: Use “an” instead of ‘a’ if the following word starts with a vowel sound, e.g. ‘an article’, ‘an hour’.\n" + "Message: Use “an” instead of \u2018a\u2019 if the following word starts " + "with a vowel sound, e.g. \u2018an article\u2019, \u2018an hour\u2019.\n" "Suggestion: an\n" - "A sentence with a error in the Hitchhiker’s Guide tot he ..." + "A sentence with a error in the Hitchhiker\u2019s Guide tot he ..." "\n ^" ) def test_correct_en_us() -> None: - """ - Test the automatic correction functionality for US English text. + """Test the automatic correction functionality for US English text. + This test verifies that LanguageTool can automatically correct grammar and spelling - errors in a given text using US English rules, replacing errors with suggested corrections. + errors in a given text using US English rules, replacing errors with suggested + corrections. :raises AssertionError: If the corrected text does not match the expected output. """ - import language_tool_python - with language_tool_python.LanguageTool("en-US") as tool: matches = tool.check("cz of this brand is awsome,,i love this brand very much") - assert len(matches) == 4 + assert len(matches) == EXPECTED_CORRECTED_MATCH_COUNT assert ( tool.correct("cz of this brand is awsome,,i love this brand very much") @@ -142,17 +149,15 @@ def test_correct_en_us() -> None: def test_spellcheck_en_gb() -> None: - """ - Test the spell-checking enable/disable functionality for British English. - This test verifies that LanguageTool can toggle spell-checking on and off, demonstrating - that disabling spell-checking prevents spelling corrections while grammar corrections - may still be applied. + """Test the spell-checking enable/disable functionality for British English. + + This test verifies that LanguageTool can toggle spell-checking on and off, + demonstrating that disabling spell-checking prevents spelling corrections while + grammar corrections may still be applied. :raises AssertionError: If the corrected text does not behave as expected when - spell-checking is enabled or disabled. + spell-checking is enabled or disabled. """ - import language_tool_python - s = "Wat is wrong with the spll chker" # Correct a sentence with spell-checking @@ -165,42 +170,42 @@ def test_spellcheck_en_gb() -> None: def test_special_char_in_text() -> None: - """ - Test that LanguageTool correctly handles text containing special characters and emojis. - This test verifies that the tool can identify and correct errors in text that includes - Unicode characters such as emojis, ensuring proper offset calculation and error detection - despite the presence of multi-byte characters. + """Test LanguageTool handling of special characters and emojis. + + This test verifies that the tool can identify and correct errors in text that + includes Unicode characters such as emojis, ensuring proper offset calculation and + error detection despite the presence of multi-byte characters. :raises AssertionError: If the corrected text does not match the expected output or - if special characters are not handled correctly. + if special characters are not handled correctly. """ - import language_tool_python - with language_tool_python.LanguageTool("en-US") as tool: - text = "The sun was seting 🌅, casting a warm glow over the park. Birds chirpped softly 🐦 as the day slowly fade into night." - assert ( - tool.correct(text) - == "The sun was setting 🌅, casting a warm glow over the park. Birds chipped softly 🐦 as the day slowly fade into night." + text = ( + "The sun was seting 🌅, casting a warm glow over the park. Birds " + "chirpped softly 🐦 as the day slowly fade into night." + ) + assert tool.correct(text) == ( + "The sun was setting 🌅, casting a warm glow over the park. Birds " + "chipped softly 🐦 as the day slowly fade into night." ) def test_check_with_regex() -> None: - """ - Test the check_matching_regions method for selective grammar checking. + """Test the check_matching_regions method for selective grammar checking. + This test verifies that LanguageTool can limit its grammar checking to specific - regions of text defined by a regular expression, allowing for targeted error detection. - Additionally, the test is performed with some special characters in the text to ensure - correct handling of offsets. + regions of text defined by a regular expression, allowing for targeted error + detection. Additionally, the test is performed with some special characters in the + text to ensure correct handling of offsets. - :raises AssertionError: If the detected matches do not correspond to the specified regions. + :raises AssertionError: If the detected matches do not correspond to the specified + regions. """ - import language_tool_python - with language_tool_python.LanguageTool("en-US") as tool: text = '❗ He said "❗ I has a problem" but she replied ❗ "It are fine ❗".' matches = tool.check_matching_regions(text, r'"[^"]*"') - assert len(matches) == 2 + assert len(matches) == EXPECTED_MATCH_COUNT assert ( language_tool_python.utils.correct(text, matches) == '❗ He said "❗ I have a problem" but she replied ❗ "It is fine ❗".' diff --git a/tests/test_safe_zip.py b/tests/test_safe_zip.py index d3c7f13..7df4699 100644 --- a/tests/test_safe_zip.py +++ b/tests/test_safe_zip.py @@ -4,7 +4,6 @@ import hashlib import importlib import io -import os import shutil import stat import uuid @@ -19,11 +18,16 @@ from language_tool_python.exceptions import PathError from language_tool_python.safe_zip import SafeZipExtractor, SafeZipLimits +EXPECTED_MAX_ARCHIVE_BYTES = 11 +EXPECTED_MAX_EXTRACTED_BYTES = 22 +EXPECTED_MAX_MEMBERS = 33 +EXPECTED_MAX_MEMBER_EXTRACTED_BYTES = 44 +EXPECTED_MAX_MEMBER_COMPRESSION_RATIO = 55.5 +EXPECTED_MAX_TOTAL_COMPRESSION_RATIO = 66.5 + def make_zip_payload(files: dict[str, bytes]) -> bytes: - """ - Create an in-memory ZIP payload for safe extraction tests. - """ + """Create an in-memory ZIP payload for safe extraction tests.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zip_file: for filename, payload in files.items(): @@ -32,9 +36,7 @@ def make_zip_payload(files: dict[str, bytes]) -> bytes: def make_deflated_zip_payload(files: dict[str, bytes]) -> bytes: - """ - Create an in-memory ZIP payload using DEFLATE compression. - """ + """Create an in-memory ZIP payload using DEFLATE compression.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zip_file: for filename, payload in files.items(): @@ -43,9 +45,7 @@ def make_deflated_zip_payload(files: dict[str, bytes]) -> bytes: def make_zip_payload_from_info(member: zipfile.ZipInfo, payload: bytes) -> bytes: - """ - Create an in-memory ZIP payload with explicit member metadata. - """ + """Create an in-memory ZIP payload with explicit member metadata.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zip_file: zip_file.writestr(member, payload) @@ -58,20 +58,16 @@ def make_symlink_or_skip( *, target_is_directory: bool = False, ) -> None: - """ - Create a symlink, or skip the test if the platform disallows it. - """ + """Create a symlink, or skip the test if the platform disallows it.""" try: - os.symlink(target, link, target_is_directory=target_is_directory) + link.symlink_to(target, target_is_directory=target_is_directory) except (NotImplementedError, OSError) as error: pytest.skip(f"Cannot create symlink for this test: {error}") @contextmanager def workspace_temp_dir() -> Iterator[Path]: - """ - Create a temporary directory inside the repository workspace. - """ + """Create a temporary directory inside the repository workspace.""" root = Path.cwd() / ".test_safe_zip_tmp" path = root / uuid.uuid4().hex path.mkdir(parents=True) @@ -84,14 +80,12 @@ def workspace_temp_dir() -> Iterator[Path]: def test_safe_extract_allows_regular_zip() -> None: - """ - Test that a regular ZIP is extracted by the safe extractor. - """ + """Test that a regular ZIP is extracted by the safe extractor.""" payload = make_zip_payload( { "LanguageTool-6.9-SNAPSHOT/": b"", "LanguageTool-6.9-SNAPSHOT/languagetool-server.jar": b"jar", - } + }, ) with ( @@ -107,36 +101,50 @@ def test_safe_extract_allows_regular_zip() -> None: def test_safe_zip_limits_use_env_overrides( monkeypatch: pytest.MonkeyPatch, ) -> None: - """ - Test that safe ZIP limits can be configured from the environment. - """ + """Test that safe ZIP limits can be configured from the environment.""" try: with monkeypatch.context() as env: - env.setenv(safe_zip.LTP_SAFE_ZIP_MAX_ARCHIVE_BYTES_ENV_VAR, "11") - env.setenv(safe_zip.LTP_SAFE_ZIP_MAX_EXTRACTED_BYTES_ENV_VAR, "22") - env.setenv(safe_zip.LTP_SAFE_ZIP_MAX_MEMBERS_ENV_VAR, "33") + env.setenv( + safe_zip.LTP_SAFE_ZIP_MAX_ARCHIVE_BYTES_ENV_VAR, + str(EXPECTED_MAX_ARCHIVE_BYTES), + ) + env.setenv( + safe_zip.LTP_SAFE_ZIP_MAX_EXTRACTED_BYTES_ENV_VAR, + str(EXPECTED_MAX_EXTRACTED_BYTES), + ) + env.setenv( + safe_zip.LTP_SAFE_ZIP_MAX_MEMBERS_ENV_VAR, str(EXPECTED_MAX_MEMBERS) + ) env.setenv( safe_zip.LTP_SAFE_ZIP_MAX_MEMBER_EXTRACTED_BYTES_ENV_VAR, - "44", + str(EXPECTED_MAX_MEMBER_EXTRACTED_BYTES), ) env.setenv( safe_zip.LTP_SAFE_ZIP_MAX_MEMBER_COMPRESSION_RATIO_ENV_VAR, - "55.5", + str(EXPECTED_MAX_MEMBER_COMPRESSION_RATIO), ) env.setenv( safe_zip.LTP_SAFE_ZIP_MAX_TOTAL_COMPRESSION_RATIO_ENV_VAR, - "66.5", + str(EXPECTED_MAX_TOTAL_COMPRESSION_RATIO), ) reloaded_safe_zip = importlib.reload(safe_zip) limits = reloaded_safe_zip.SafeZipLimits() - assert limits.max_archive_bytes == 11 - assert limits.max_extracted_bytes == 22 - assert limits.max_members == 33 - assert limits.max_member_extracted_bytes == 44 - assert limits.max_member_compression_ratio == 55.5 - assert limits.max_total_compression_ratio == 66.5 + assert limits.max_archive_bytes == EXPECTED_MAX_ARCHIVE_BYTES + assert limits.max_extracted_bytes == EXPECTED_MAX_EXTRACTED_BYTES + assert limits.max_members == EXPECTED_MAX_MEMBERS + assert ( + limits.max_member_extracted_bytes == EXPECTED_MAX_MEMBER_EXTRACTED_BYTES + ) + assert ( + limits.max_member_compression_ratio + == EXPECTED_MAX_MEMBER_COMPRESSION_RATIO + ) + assert ( + limits.max_total_compression_ratio + == EXPECTED_MAX_TOTAL_COMPRESSION_RATIO + ) finally: importlib.reload(safe_zip) @@ -146,9 +154,7 @@ def test_safe_zip_float_env_rejects_non_finite_values( monkeypatch: pytest.MonkeyPatch, configured: str, ) -> None: - """ - Test that non-finite ratio limits are rejected. - """ + """Test that non-finite ratio limits are rejected.""" env_var = "LTP_TEST_SAFE_ZIP_FLOAT" monkeypatch.setenv(env_var, configured) @@ -184,9 +190,7 @@ def test_safe_zip_float_env_rejects_non_finite_values( def test_safe_extract_rejects_unsafe_member_names( filename: str, ) -> None: - """ - Test that unsafe ZIP member names are rejected. - """ + """Test that unsafe ZIP member names are rejected.""" payload = make_zip_payload({filename: b"nope"}) with ( @@ -198,9 +202,7 @@ def test_safe_extract_rejects_unsafe_member_names( def test_safe_extract_rejects_duplicate_member_paths() -> None: - """ - Test that duplicate ZIP member paths are rejected before extraction. - """ + """Test that duplicate ZIP member paths are rejected before extraction.""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zip_file: zip_file.writestr("LanguageTool/file.txt", b"one") @@ -216,47 +218,41 @@ def test_safe_extract_rejects_duplicate_member_paths() -> None: def test_safe_extract_rejects_file_directory_conflict() -> None: - """ - Test that archives cannot contain both a file and children below that file path. - """ + """Test that archives reject file-and-child path conflicts.""" payload = make_zip_payload( { "LanguageTool/path": b"file", "LanguageTool/path/child.txt": b"child", - } + }, ) with ( workspace_temp_dir() as temp_dir, zipfile.ZipFile(io.BytesIO(payload)) as zip_file, - pytest.raises(PathError, match="below file path|file over directory path"), + pytest.raises(PathError, match=r"below file path|file over directory path"), ): SafeZipExtractor().extractall(zip_file, temp_dir) def test_safe_extract_rejects_file_directory_conflict_in_reverse_order() -> None: - """ - Test that archives cannot replace a directory path with a file path. - """ + """Test that archives cannot replace a directory path with a file path.""" payload = make_zip_payload( { "LanguageTool/path/child.txt": b"child", "LanguageTool/path": b"file", - } + }, ) with ( workspace_temp_dir() as temp_dir, zipfile.ZipFile(io.BytesIO(payload)) as zip_file, - pytest.raises(PathError, match="below file path|file over directory path"), + pytest.raises(PathError, match=r"below file path|file over directory path"), ): SafeZipExtractor().extractall(zip_file, temp_dir) def test_safe_extract_rejects_zip_symlink() -> None: - """ - Test that ZIP symlink entries are rejected. - """ + """Test that ZIP symlink entries are rejected.""" member = zipfile.ZipInfo("LanguageTool/link") member.create_system = 3 member.external_attr = (stat.S_IFLNK | 0o777) << 16 @@ -271,9 +267,7 @@ def test_safe_extract_rejects_zip_symlink() -> None: def test_safe_extract_rejects_symlinked_destination() -> None: - """ - Test that the final destination itself cannot be a symlink. - """ + """Test that the final destination itself cannot be a symlink.""" payload = make_zip_payload({"LanguageTool/file.txt": b"jar"}) with ( @@ -300,9 +294,7 @@ def test_safe_extract_rejects_symlinked_destination() -> None: def test_safe_extract_rejects_existing_symlink_in_destination() -> None: - """ - Test that an existing destination symlink cannot redirect extracted content. - """ + """Test that an existing destination symlink cannot redirect extracted content.""" payload = make_zip_payload({"LanguageTool/file.txt": b"jar"}) with ( @@ -321,7 +313,7 @@ def test_safe_extract_rejects_existing_symlink_in_destination() -> None: with pytest.raises( PathError, - match="Unsafe extracted ZIP destination path|overwrite existing path", + match=r"Unsafe extracted ZIP destination path|overwrite existing path", ): SafeZipExtractor().extractall( zip_file, @@ -333,9 +325,7 @@ def test_safe_extract_rejects_existing_symlink_in_destination() -> None: def test_safe_extract_rejects_symlinked_work_dir() -> None: - """ - Test that the private extraction work directory cannot be a symlink. - """ + """Test that the private extraction work directory cannot be a symlink.""" payload = make_zip_payload({"LanguageTool/file.txt": b"jar"}) with ( @@ -356,9 +346,7 @@ def test_safe_extract_rejects_symlinked_work_dir() -> None: def test_safe_extract_rejects_special_zip_member_type() -> None: - """ - Test that non-file, non-directory ZIP entries are rejected. - """ + """Test that non-file, non-directory ZIP entries are rejected.""" member = zipfile.ZipInfo("LanguageTool/fifo") member.create_system = 3 member.external_attr = (stat.S_IFIFO | 0o644) << 16 @@ -373,14 +361,12 @@ def test_safe_extract_rejects_special_zip_member_type() -> None: def test_safe_extract_allows_multiple_safe_roots() -> None: - """ - Test that safe extraction does not require a LanguageTool-specific root. - """ + """Test that safe extraction does not require a LanguageTool-specific root.""" payload = make_zip_payload( { "first/file.txt": b"one", "second/file.txt": b"two", - } + }, ) with ( @@ -396,9 +382,7 @@ def test_safe_extract_allows_multiple_safe_roots() -> None: def test_safe_extract_rejects_existing_destination_path() -> None: - """ - Test that extraction never overwrites an existing final destination path. - """ + """Test that extraction never overwrites an existing final destination path.""" payload = make_zip_payload({"file.txt": b"new"}) with ( @@ -421,14 +405,12 @@ def test_safe_extract_rejects_existing_destination_path() -> None: def test_safe_extract_rejects_too_many_members() -> None: - """ - Test that ZIP archives with too many entries are rejected. - """ + """Test that ZIP archives with too many entries are rejected.""" payload = make_zip_payload( { "LanguageTool/one.txt": b"one", "LanguageTool/two.txt": b"two", - } + }, ) extractor = SafeZipExtractor(SafeZipLimits(max_members=1)) @@ -441,9 +423,7 @@ def test_safe_extract_rejects_too_many_members() -> None: def test_safe_extract_rejects_too_much_uncompressed_data() -> None: - """ - Test that ZIP archives with too much uncompressed data are rejected. - """ + """Test that ZIP archives with too much uncompressed data are rejected.""" payload = make_zip_payload({"LanguageTool/file.txt": b"four"}) extractor = SafeZipExtractor(SafeZipLimits(max_extracted_bytes=3)) @@ -456,15 +436,13 @@ def test_safe_extract_rejects_too_much_uncompressed_data() -> None: def test_safe_extract_rejects_oversized_member_during_copy() -> None: - """ - Test that per-member extracted size limits are enforced while copying. - """ + """Test that per-member extracted size limits are enforced while copying.""" payload = make_zip_payload({"LanguageTool/file.txt": b"four"}) extractor = SafeZipExtractor( SafeZipLimits( max_extracted_bytes=100, max_member_extracted_bytes=3, - ) + ), ) with ( @@ -476,9 +454,7 @@ def test_safe_extract_rejects_oversized_member_during_copy() -> None: def test_safe_extract_rejects_too_much_compressed_data() -> None: - """ - Test that local ZIP extraction also applies the compressed-size limit. - """ + """Test that local ZIP extraction also applies the compressed-size limit.""" payload = make_zip_payload({"LanguageTool/file.txt": b"data"}) extractor = SafeZipExtractor(SafeZipLimits(max_archive_bytes=1)) @@ -491,15 +467,13 @@ def test_safe_extract_rejects_too_much_compressed_data() -> None: def test_safe_extract_rejects_suspicious_member_compression_ratio() -> None: - """ - Test that a single member with an abusive compression ratio is rejected. - """ + """Test that a single member with an abusive compression ratio is rejected.""" payload = make_deflated_zip_payload({"LanguageTool/file.txt": b"A" * 4096}) extractor = SafeZipExtractor( SafeZipLimits( max_member_compression_ratio=2.0, max_total_compression_ratio=10_000.0, - ) + ), ) with ( @@ -511,15 +485,13 @@ def test_safe_extract_rejects_suspicious_member_compression_ratio() -> None: def test_safe_extract_rejects_suspicious_total_compression_ratio() -> None: - """ - Test that an archive with an abusive total compression ratio is rejected. - """ + """Test that an archive with an abusive total compression ratio is rejected.""" payload = make_deflated_zip_payload({"LanguageTool/file.txt": b"A" * 4096}) extractor = SafeZipExtractor( SafeZipLimits( max_member_compression_ratio=10_000.0, max_total_compression_ratio=2.0, - ) + ), ) with ( @@ -531,9 +503,7 @@ def test_safe_extract_rejects_suspicious_total_compression_ratio() -> None: def test_safe_extract_checks_total_compression_ratio_after_all_members() -> None: - """ - Test that total ratio checks are based on the final archive ratio. - """ + """Test that total ratio checks are based on the final archive ratio.""" already_compressed = b"".join( hashlib.sha256(index.to_bytes(4, "big")).digest() for index in range(2048) ) @@ -541,13 +511,13 @@ def test_safe_extract_checks_total_compression_ratio_after_all_members() -> None { "LanguageTool/compressible.txt": b"A" * 4096, "LanguageTool/already-compressed.bin": already_compressed, - } + }, ) extractor = SafeZipExtractor( SafeZipLimits( max_member_compression_ratio=1_000.0, max_total_compression_ratio=5.0, - ) + ), ) with ( diff --git a/tests/test_server_local.py b/tests/test_server_local.py index bfad711..c08b904 100644 --- a/tests/test_server_local.py +++ b/tests/test_server_local.py @@ -1,25 +1,34 @@ """Tests for the local server functionality of LanguageTool.""" -import subprocess +from __future__ import annotations + +import hashlib import time -from typing import Optional +from typing import TYPE_CHECKING + +import language_tool_python +from language_tool_python.download_lt import LTP_DOWNLOAD_VERSION +from language_tool_python.utils import get_language_tool_download_path + +if TYPE_CHECKING: + import subprocess def test_process_starts_and_stops_in_context_manager() -> None: - """ - Test that the LanguageTool server process starts and stops correctly with context manager. - This test verifies that when using LanguageTool as a context manager, the server process - is running while inside the context and is properly terminated when exiting the context. + """Test server startup and shutdown with context manager. - :raises AssertionError: If the server process is not running after creation or - if it continues running after context manager exit. - """ - import language_tool_python + This test verifies that when using LanguageTool as a context manager, the server + process is running while inside the context and is properly terminated when exiting + the context. + :raises AssertionError: If the server process is not running after creation or if it + continues running after context manager exit. + """ with language_tool_python.LanguageTool("en-US") as tool: - proc: Optional[subprocess.Popen[str]] = tool._server + proc: subprocess.Popen[str] | None = tool._server if proc is None: - raise AssertionError("tool._server is None after creation") + err = "tool._server is None after creation" + raise AssertionError(err) # Make sure process is running before killing language tool object. assert proc.poll() is None, "tool._server not running after creation" time.sleep(0.5) # Give some time for process to stop after context manager exit. @@ -28,23 +37,23 @@ def test_process_starts_and_stops_in_context_manager() -> None: def test_process_starts_and_stops_on_close() -> None: - """ - Test that the LanguageTool server process starts and stops correctly with explicit close(). + """Test server startup and shutdown with explicit close(). + This test verifies that when explicitly calling close() on a LanguageTool instance, the server process is properly terminated before object deletion. - :raises AssertionError: If the server process is not running after creation or - if it continues running after close() is called. + :raises AssertionError: If the server process is not running after creation or if it + continues running after close() is called. """ - import language_tool_python - tool = language_tool_python.LanguageTool("en-US") - proc: Optional[subprocess.Popen[str]] = tool._server + proc: subprocess.Popen[str] | None = tool._server if proc is None: - raise AssertionError("tool._server is None after creation") + err = "tool._server is None after creation" + raise AssertionError(err) # Make sure process is running before killing language tool object. assert proc.poll() is None, "tool._server not running after creation" - tool.close() # Explicitly close() object so process stops before garbage collection. + # Explicitly close() object so process stops before garbage collection. + tool.close() del tool # Make sure process stopped after close() was called. time.sleep(0.5) # Give some time for process to stop after close() call. @@ -53,38 +62,31 @@ def test_process_starts_and_stops_on_close() -> None: def test_local_client_server_connection() -> None: - """ - Test client-server connection between two LanguageTool instances. + """Test client-server connection between two LanguageTool instances. + This test verifies that a LanguageTool instance can act as a server and another - instance can successfully connect to it as a remote client, allowing grammar checking - to be performed through the client-server architecture. + instance can successfully connect to it as a remote client, allowing grammar + checking to be performed through the client-server architecture. - :raises AssertionError: If the client cannot successfully check text through the server. + :raises AssertionError: If the client cannot successfully check text through the + server. """ - import language_tool_python - with language_tool_python.LanguageTool("en-US", host="127.0.0.1") as tool1: - url = "http://{}:{}/".format(tool1._host, tool1._port) + url = f"http://{tool1._host}:{tool1._port}/" with language_tool_python.LanguageTool("en-US", remote_server=url) as tool2: assert len(tool2.check("helo darknes my old frend")) def test_session_only_new_spellings() -> None: - """ - Test that session-only new spellings do not persist to the spelling file. + """Test that session-only new spellings do not persist to the spelling file. + This test verifies that when new_spellings_persist is set to False, custom spellings added during a session are recognized by the tool but do not modify the permanent spelling dictionary file. :raises AssertionError: If the spelling file is modified or if new spellings are not - recognized during the session. + recognized during the session. """ - import hashlib - - import language_tool_python - from language_tool_python.download_lt import LTP_DOWNLOAD_VERSION - from language_tool_python.utils import get_language_tool_download_path - library_path = ( get_language_tool_download_path() / f"LanguageTool-{LTP_DOWNLOAD_VERSION}" ) @@ -97,7 +99,7 @@ def test_session_only_new_spellings() -> None: / "hunspell" / "spelling.txt" ) - with open(spelling_file_path, "r") as spelling_file: + with spelling_file_path.open("r", encoding="utf-8") as spelling_file: initial_spelling_file_contents = spelling_file.read() initial_checksum = hashlib.sha256(initial_spelling_file_contents.encode()) @@ -111,12 +113,14 @@ def test_session_only_new_spellings() -> None: tool.enabled_rules = {"MORFOLOGIK_RULE_EN_US"} matches = tool.check(" ".join(new_spellings)) - with open(spelling_file_path, "r") as spelling_file: + with spelling_file_path.open("r", encoding="utf-8") as spelling_file: subsequent_spelling_file_contents = spelling_file.read() subsequent_checksum = hashlib.sha256(subsequent_spelling_file_contents.encode()) if initial_checksum != subsequent_checksum: - with open(spelling_file_path, "w") as spelling_file: + with spelling_file_path.open( + "w", encoding="utf-8", newline="\n" + ) as spelling_file: spelling_file.write(initial_spelling_file_contents) assert not matches @@ -124,35 +128,35 @@ def test_session_only_new_spellings() -> None: def test_new_spellins_in_es() -> None: - """ - Test that new spellings are recognized in Spanish language. - This test verifies that when new_spellings are added for the Spanish language, - they are correctly recognized by the tool during grammar checking. - (This test is important to ensure that the new spellings functionality works - across different languages and is not limited to English.) + """Test that new spellings are recognized in Spanish language. + + This test verifies that when new_spellings are added for the Spanish language, they + are correctly recognized by the tool during grammar checking. (This test is + important to ensure that the new spellings functionality works across different + languages and is not limited to English.) :raises AssertionError: If the new spellings are not recognized in Spanish. """ - import language_tool_python - with language_tool_python.LanguageTool( - "es", new_spellings=["ejempo"], new_spellings_persist=False + "es", + new_spellings=["ejempo"], + new_spellings_persist=False, ) as tool: matches = tool.check("Este es un ejempo sencillo.") assert not matches def test_uk_typo() -> None: - """ - Test grammar checking and correction with UK English language rules. - This test verifies that LanguageTool correctly identifies and corrects grammar errors - specific to UK English, including proper handling of contractions like "you're" and "your", - while respecting UK English conventions where "You're" can mean "Your" in certain contexts. + """Test grammar checking and correction with UK English language rules. - :raises AssertionError: If the detected errors or corrections do not match expected UK English rules. - """ - import language_tool_python + This test verifies that LanguageTool correctly identifies and corrects grammar + errors specific to UK English, including proper handling of contractions like + "you're" and "your", while respecting UK English conventions where "You're" can mean + "Your" in certain contexts. + :raises AssertionError: If the detected errors or corrections do not match expected + UK English rules. + """ with language_tool_python.LanguageTool("en-UK") as tool: sentence1 = "If you think this sentence is fine then, your wrong." results1 = tool.check(sentence1) diff --git a/uv.lock b/uv.lock index 9bf5cf5..a096d78 100644 --- a/uv.lock +++ b/uv.lock @@ -50,40 +50,42 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/1f/50f241d4e01fe75f4bba6a209edd4047c4b26acf70992ff885fd161f79cb/ast_serialize-0.4.0.tar.gz", hash = "sha256:74e4e634ab82d1466acf0be27043178570b98ebeaa3165f9240a6fad4c286471", size = 60687, upload-time = "2026-05-14T22:44:38.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/85/232631c59b5ca7152c08f026e9a46f47d852298acff74edd04a1fc1d0005/ast_serialize-0.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a6f26937ce0293aafbece0e39019e020369a5a70486ff4088227f0cc888844a9", size = 1182685, upload-time = "2026-05-14T22:43:40.205Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/4838d4d3ddc4425555601467d4e2a565e4340899e45feee4e32c80fbc911/ast_serialize-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:074032142777e3e6091977dc3c5146a8ca58ae6825b7f64e9a0b604153ddabd8", size = 1173113, upload-time = "2026-05-14T22:43:41.937Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/d622b19fc1c79a62028ec17f4ad4323177af25b174d32b07c84d61ef9d47/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:404f3462b4532e13a70b8849bba241dbd82e30043ff58d98c7e762fd925b116a", size = 1234117, upload-time = "2026-05-14T22:43:43.977Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b5/72f8c8659da0b64562e6d97f852d5c2022c74577df27c922e1e7065039ce/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97c55336e16f5c4ca2bde7be94cca4b8f7d665d64f7008925a82e02707ba14ac", size = 1231703, upload-time = "2026-05-14T22:43:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/7b/98/ccc51ee4f90f97a1ed0a0848bd4c9d77a80969849db8a262b7d2970a6a15/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:732b4ef76adcb0f298a7d18c4558336d83b1384f9ae0c7eaa1dc8d031b0a4390", size = 1441574, upload-time = "2026-05-14T22:43:47.784Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ce/668c4efe79e09c9cc97a4d0a1c29e61fe6f78857fe1e57c086772af55f89/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b3db87c4772097c0782250bcd550d66b1189a8c889793c7bcf153f4fee70005c", size = 1254040, upload-time = "2026-05-14T22:43:49.879Z" }, - { url = "https://files.pythonhosted.org/packages/3d/be/38b27bc2909b7236939801ca9f0d97cdc6198da4f435a81658e0db506fdb/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43729a5e369ebbe7750635c0c206bc616fcd36e703cb9c4497d6b4df0291ee64", size = 1257847, upload-time = "2026-05-14T22:43:51.607Z" }, - { url = "https://files.pythonhosted.org/packages/68/df/360ebccc361235c167a8be2a0476870cb9ef44c42413bf1289b885684052/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:91d3786f3929786cdc4eeedfd110abb4603e7f6c1390c5af398f333a947b742d", size = 1298683, upload-time = "2026-05-14T22:43:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/51/5c/7d5e0b4d47aafa1600c19e3670f962f81a9bf3da1bc25a1382529a447cf3/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7fba7315fd4bd87cb5560792709f6e66e0606402d362c0a38dd32dfb66ba6066", size = 1409438, upload-time = "2026-05-14T22:43:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/8875b2f1af3ec1539b88ff193dfbfa5573084ef7fcab27ea4cd09b6dc829/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4db9769d57deb5545ce56ebbbbe3436dcc0ae2688ce14c295cd14e106624ece7", size = 1507922, upload-time = "2026-05-14T22:43:56.959Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/5ec6927eb493ece7ba64263cdc556be889e0c62a013b1851bbe674a0dcda/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dcd04f85a29deb80400e8987cfaceb9907140f763453cbffdbd6ff36f1b32c12", size = 1502817, upload-time = "2026-05-14T22:43:59.081Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c8/40cb818a08396b1f34d6189c0c42aec917dd331e11fb7c3b870cc61b795a/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:905fc11940831454d93589bd7ce2acb6a5eb01c2936156f751d2a21087c98cd3", size = 1454318, upload-time = "2026-05-14T22:44:01.377Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/d51494b60cc52f4792be5ddc951631cddb17a2990154634549abdbdbb5bf/ast_serialize-0.4.0-cp314-cp314t-win32.whl", hash = "sha256:3bdde2c4570143791f636aed4e3ef868f5b46eb90a18f8d5c41dd045aab08bef", size = 1060098, upload-time = "2026-05-14T22:44:03.265Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c9/b0086257c79ff95743a3621448a01fc71b234ae359d3d54cda383aa43939/ast_serialize-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6551d55b8607b97a7755683d743200b398c61a0b71a11b7f00c89c335a11d0f4", size = 1101015, upload-time = "2026-05-14T22:44:05.055Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6d/3dfddef4990fda47745af6615a3e51c4de711eda56c3a8072a0d8b6181c7/ast_serialize-0.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7234ff086cb152ea2a3b7ef895b5ebeb6d80779df049d5c6431c8e3536d5b03c", size = 1074495, upload-time = "2026-05-14T22:44:07.186Z" }, - { url = "https://files.pythonhosted.org/packages/be/d5/044c5f995ef75807a0effb56fc288cfdedeeb571222450fb6f7d94fd52f1/ast_serialize-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dcded5056d9f3d201df7833082c07ebcbc566ffc3d4105c9fc9fe278fa086ecb", size = 1189800, upload-time = "2026-05-14T22:44:09.333Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5a/52163557789d59a8197c10912ab4a1791c9143731ba0e3d9283ac0791db6/ast_serialize-0.4.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd50d201098aae0d202805fe9606c0545492f69a3ec4403337e32c54ad29fc41", size = 1181713, upload-time = "2026-05-14T22:44:11.286Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c3/678ce3b6cb594b01c361da87f6c5679d26c1dae1583a082a8cd190e7232e/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6615b39cd747967c3aabe68bf3f5f26748e823cc6b474ddc1510ed188a824149", size = 1243258, upload-time = "2026-05-14T22:44:13.345Z" }, - { url = "https://files.pythonhosted.org/packages/3d/dd/4810fbeb81c47b7e4e65db15ca65c71330efc59b460bd10c12338dc6012e/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91362c0a9fdf1c344b7f50a5b0508b11a0732102998fbd754a191f7187e77031", size = 1239226, upload-time = "2026-05-14T22:44:15.811Z" }, - { url = "https://files.pythonhosted.org/packages/28/38/13a88d90b664c009ed208346ec2ed248b0ab2cb0b582ae467acaa7f44fa4/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70d9c5d527bbfa69bd3c7d17dac11fb6781e36186a434a06d7d5892e0b2f88f9", size = 1448867, upload-time = "2026-05-14T22:44:17.99Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/a069dba1a634b703bf07fb49df8f7e3c04e9ba8ef3f0d9f4495f72630f92/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4738790cf54d8b416de992b87ee567056980bc82134d52458bd4985f389d1658", size = 1264135, upload-time = "2026-05-14T22:44:19.8Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4c/76ec4279fecd7e78b60c3c99321f944c43cd11e5ff09c952746f5f9c0f4c/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:faa008dccfcb793ae9101325e4d6d026caaa5d845c2182f03749c759834b0a3a", size = 1269060, upload-time = "2026-05-14T22:44:21.894Z" }, - { url = "https://files.pythonhosted.org/packages/33/c5/9230ef7481e5cb63b93a1f7738e959586202b081caf32b8bc5d9f673ef56/ast_serialize-0.4.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c5245228e65d38cb48e1251f0ca71b0fa417e527141491e8c92f740e8e2d121", size = 1309654, upload-time = "2026-05-14T22:44:23.725Z" }, - { url = "https://files.pythonhosted.org/packages/b9/54/7d7397528d181ad68e476e0c81aa3ceff7d1f1b5c7fa958d6be28628ef16/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8f5153e9c44a02e61f4042c5f9249d2e8a759773d621a0b2f445a899e536e181", size = 1418855, upload-time = "2026-05-14T22:44:25.415Z" }, - { url = "https://files.pythonhosted.org/packages/b8/8f/87d6428adaa0986b817404f09329b64f8d2614cfe061ebf4951b4a7e0d19/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1e1fb90def261f6a0db885876f7e1a49ad2dbac38ad9f2f62dba2f9543af16e7", size = 1516040, upload-time = "2026-05-14T22:44:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/b5/bb/5aaa41a21314c8b0d6dee54867b16535682c6660dd28cac64dba1380062d/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf2ff7b654c8e95143e20f5d75878cbb78b65b928b26c4d58ef71cdba9d6d981", size = 1511450, upload-time = "2026-05-14T22:44:29.522Z" }, - { url = "https://files.pythonhosted.org/packages/87/16/cc729b5bb4b21da99db1379266cc367512e82ba10f9b3300a6f3e9941325/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90fc5c0d35a22f1a92dd33635508626d50f8fc64deb897c23e78e666a60804c9", size = 1463654, upload-time = "2026-05-14T22:44:31.265Z" }, - { url = "https://files.pythonhosted.org/packages/43/97/7198321b0244d011093387b41affea934d58bda08d59a2adfde72976b6c4/ast_serialize-0.4.0-cp39-abi3-win32.whl", hash = "sha256:9ecd6a1fc1b86f1f4e8ae206759b6319c10019706b3496b01b54d02b9b2cd918", size = 1068636, upload-time = "2026-05-14T22:44:33.189Z" }, - { url = "https://files.pythonhosted.org/packages/10/09/3b868f6d8df4bbe452903a5e0e039ebcec9ea0045f1a77951546205097e8/ast_serialize-0.4.0-cp39-abi3-win_amd64.whl", hash = "sha256:79c8d015c771c8bfdb1208003b227b27c40034790a2c29c09f2317a041825ce2", size = 1107137, upload-time = "2026-05-14T22:44:35.304Z" }, - { url = "https://files.pythonhosted.org/packages/fd/78/9387dffccdc55a12734f83aaccc4a987404a217a2a12a1920d8d4585950b/ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407", size = 1079199, upload-time = "2026-05-14T22:44:36.816Z" }, +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] @@ -97,24 +99,24 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -368,7 +370,7 @@ toml = [ [[package]] name = "coverage" -version = "7.14.0" +version = "7.14.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -376,113 +378,113 @@ resolution-markers = [ "python_full_version == '3.11.*'", "python_full_version == '3.10.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, - { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, - { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [package.optional-dependencies] @@ -550,11 +552,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -669,13 +671,13 @@ tests = [ ] types = [ { name = "types-psutil", version = "7.2.2.20260130", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "types-psutil", version = "7.2.2.20260508", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "types-psutil", version = "7.2.2.20260518", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "types-requests", version = "2.32.4.20260107", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "types-requests", version = "2.33.0.20260513", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "types-requests", version = "2.33.0.20260518", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "types-toml", version = "0.10.8.20240310", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "types-toml", version = "0.10.8.20260508", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "types-toml", version = "0.10.8.20260518", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "types-tqdm", version = "4.67.3.20260205", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "types-tqdm", version = "4.67.3.20260508", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "types-tqdm", version = "4.68.0.20260608", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [package.metadata] @@ -694,8 +696,8 @@ docs = [ { name = "sphinx-design" }, ] quality = [ - { name = "mypy", marker = "python_full_version >= '3.10'", specifier = "==2.0.0" }, - { name = "ruff", specifier = "==0.15.12" }, + { name = "mypy", marker = "python_full_version >= '3.10'", specifier = "==2.1.0" }, + { name = "ruff", specifier = "==0.15.16" }, ] tests = [ { name = "pytest" }, @@ -903,7 +905,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize", marker = "python_full_version >= '3.10'" }, @@ -913,51 +915,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version == '3.10.*'" }, { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/1e/9983d2d5b5d2dc3677177bcf0fa6b25185ecf750cc0559e02199625a31c5/mypy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:65d6f22d643bccaeb182d41d2a9f0990a05a871673c4ae3f97d4931eca0d2294", size = 14663140, upload-time = "2026-05-06T19:25:59.474Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/b4009c91d3ced13c8f406acf47bbe56365025cd21bf6585cd1e87375a708/mypy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:106650bce72114f43019bf72197296f51c2cd47adfa9d073ea2976c247a404c5", size = 13526733, upload-time = "2026-05-06T19:22:56.425Z" }, - { url = "https://files.pythonhosted.org/packages/f0/99/2403cb0ceeb1552f70e70e779e3d0713b24f84c7ca0e9e14b2b7bc684cf0/mypy-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c734b7eb89a4cc4ec347f8187ffa730e2b59693407bc93dcb878183037f80a17", size = 13951940, upload-time = "2026-05-06T19:24:43.45Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f7/4848a14c2667b6eb62841c9aeb7e1f6479613b1ef9a65564fe1f5518a35b/mypy-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd9e60388944d0f1432a2419ab938a78d5658df1d143a7172cfe1a197276cf49", size = 14833983, upload-time = "2026-05-06T19:23:16.827Z" }, - { url = "https://files.pythonhosted.org/packages/ec/28/c51831f9f1c6e46cbce765bd0a18981b84696e40bd1eea14e0a08494af44/mypy-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95e3890666c3be41af7a7179f4872341c08e90c161ba8e7a08a21f9be92c131", size = 15135591, upload-time = "2026-05-06T19:24:32.96Z" }, - { url = "https://files.pythonhosted.org/packages/40/7f/3c25e503a94f9ec18352464551bc6c506dee2bca93c6d0e0b5568eefc269/mypy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8e8709ce1b1046b8aad77a506dd01491157102dd727128c0b374b5025c7d769", size = 10983019, upload-time = "2026-05-06T19:20:30.942Z" }, - { url = "https://files.pythonhosted.org/packages/75/da/5cf833fd3b53fd4b5797e55dc16fb7efab16fddbc7205d49ff65b15d554e/mypy-2.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:0165968759c99ab79dc1a9f8aaec18e93a1bedcf7c13edd70e68dd3d5faf17cb", size = 9914165, upload-time = "2026-05-06T19:21:49.165Z" }, - { url = "https://files.pythonhosted.org/packages/8b/1e/268b81393b81d64683f670680215553e70ae92c55805915b3440080e05e4/mypy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17b7222e9fdfd352e61fb3131da117e55cc465f701ff232f1bd97a02bbad91f", size = 14580849, upload-time = "2026-05-06T19:23:06.567Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/d159a8002d9e5c44e59ece9d641a26956c89be5b6827f819d9a9dc678c65/mypy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0a61adea1a5ffc2d47a4dc4bb180d8103f477fc2a90a1cdcbb168c2cc6caff", size = 13444955, upload-time = "2026-05-06T19:25:11.982Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5d/3b28d5a2799591da0ee5490418e94497eaf5d701e42d8b001b5e17a9b3d6/mypy-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8578f857b519993d065e5805290b71467ebfae772407a5f57e823755e4fdb850", size = 13873124, upload-time = "2026-05-06T19:20:39.684Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/f40f723955617b814d5ddc1154d8938b77aaf6926c2dbf72846e8943a0b7/mypy-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33f668a37a650df60f7b825c1ac61e6baadd4ac3c89519e929badde58d28edf5", size = 14748822, upload-time = "2026-05-06T19:25:30.972Z" }, - { url = "https://files.pythonhosted.org/packages/d6/16/eded971224a483e422a141ffd580c00e1b919df8e529f06d03a4a987878c/mypy-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29ea6da86c8c5e9addd48fa6e624f467341b3814f54ded871b28980468686dea", size = 14992675, upload-time = "2026-05-06T19:23:34.511Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6a/1cbd7290f00b4dbaa4c4502e53ac05645ea635e4d1e3dcd42687c2fc39cd/mypy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:904baa0124ebbccf0c7ba94f722cf9186ee30478f5e5b11432ffc8929248ee55", size = 10983628, upload-time = "2026-05-06T19:26:39.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/3f/8caa9bcc2636cd512642050747466b695fa2540d7040544fd7ddb721d671/mypy-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:440165501295e523bf1e5d3e411b62b367b901c65610938e75f0e56ba0462461", size = 9906041, upload-time = "2026-05-06T19:24:03.199Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4b/f6cd12ef1eb63be1c342da3e8ca811d2280276177f6de4ef20cb2366d79b/mypy-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:660790551c988e69d8bf7a35c8b4149edeb22f4a339165702be843532e9dcdb5", size = 14756610, upload-time = "2026-05-06T19:26:19.221Z" }, - { url = "https://files.pythonhosted.org/packages/32/73/67d09ca28bee21feaca264b2a680cf2d300bcc2071136ad064928324c843/mypy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a15bf92cd8781f8e72f69ffa7e30d1f434402d065ee1ecd5223ef2ef100f914", size = 13554270, upload-time = "2026-05-06T19:26:08.977Z" }, - { url = "https://files.pythonhosted.org/packages/61/b3/44718b5c6b1b5a27440ff2effe6a1be0fa2a190c0f4e2e21a83728416f95/mypy-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ff370b43d7def05bbcd2f5267f0bcda72dd6a552ef2ea9375b02d6fe06da270", size = 13924663, upload-time = "2026-05-06T19:21:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2b/bbb9cc5773f946846a7c340097e59bcf84095437dda0d56bb4f6cf1f6541/mypy-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37bd246590a018e5a11703b7b09c39d47ede3df5ba3fa863c5b8590b465beb01", size = 14946862, upload-time = "2026-05-06T19:24:23.023Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/e9318566f443a5130b4ff0ad3367ee6c4c4c49ff083fe5214a7318c18282/mypy-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cce87e92214fac8bf8feb8a680d0c1b6fb748d50e9b57fbb13e4b1d83a3ed19b", size = 15175090, upload-time = "2026-05-06T19:26:28.794Z" }, - { url = "https://files.pythonhosted.org/packages/67/65/2ec28c834f21e164c33bc296a7db538ad50c74f83e517c7a0be95ff6de86/mypy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:e19e9cb69b66a4141009d24898259914fa2b71d026de0b46edf9fafdbf4fd46e", size = 11052899, upload-time = "2026-05-06T19:25:39.084Z" }, - { url = "https://files.pythonhosted.org/packages/9e/72/d1ec625cfc9bd101c07a6834ef1f94e820296f8fdbad2eb03f50e0983f8c/mypy-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:b021614cb08d44785b025982163ec3c39c94bff766ead071fa9e82b4ef6f62cd", size = 9972935, upload-time = "2026-05-06T19:23:24.204Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c6/996a1e535e5d0d597c3b1460fc962733091f885f312e749350eb2ac10965/mypy-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ef5f581b61240d1cc629b12f8df6565ed6ffac0d82ed745eef7833222ab50b9", size = 14737259, upload-time = "2026-05-06T19:20:23.081Z" }, - { url = "https://files.pythonhosted.org/packages/94/c5/0f9460e26b77f434bd53f47d1ce32a3cd4580c92a5331fa5dfc059f9421a/mypy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20e3470a165dbc249bdfbe8d1c5172727ef22688cffc279f8c3aa264ab9d4d9a", size = 13538377, upload-time = "2026-05-06T19:21:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3e/8ea2f8dd1e5c9c279fb3c28193bdb850adf4d3d8172880abad829eced609/mypy-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224ba142eee8b4d65d4db657cb1fc22abec30b135ded6ab297302ba1f62e505d", size = 13914264, upload-time = "2026-05-06T19:24:12.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/78bd3b8520f676acee9dab48ea71473e68f6d5cf14b59fbd800bea50a92b/mypy-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e879ad8a03908ff74d15e8a9b42bf049918e6798d52c011011f1873d0b5877e", size = 14926761, upload-time = "2026-05-06T19:20:12.846Z" }, - { url = "https://files.pythonhosted.org/packages/61/ef/b52fa340522da3d22e669117c3b83155c2660f7cdc035856958fbfffb224/mypy-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5c15bcbd18d6fe927cc55c459597a3517d69cc3123f067be3b020010e115e", size = 15157014, upload-time = "2026-05-06T19:25:49.78Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/dde7614250c6d017936c7aa3bb63b9b52c7cfd298d3f1be9be45f307870b/mypy-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1a068acd7c9fb77e9f8923f1556f2f49d6d7895821121b8d97fa5642b9c52f5", size = 11067049, upload-time = "2026-05-06T19:21:16.116Z" }, - { url = "https://files.pythonhosted.org/packages/27/ec/1d6af4830a94a285442db19caa02f160cc1a255e4f324eec5458e6c2bafb/mypy-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ef9d96da1ddffbc21f27d3939319b6846d12393baa17c4d2f3e81e040e73ce2c", size = 9967903, upload-time = "2026-05-06T19:22:15.52Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/6fefe954207860aed6eeb91776795e64a257d3ce0360862288984ce121f5/mypy-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c918c64e8ce36557851b0347f84eb12f1965d3a06813c36df253eb0c0afd1d82", size = 14729633, upload-time = "2026-05-06T19:24:53.383Z" }, - { url = "https://files.pythonhosted.org/packages/23/d6/d336f5b820af189eb0390cce21de62d264c0a4e64713dfbe81bfc4fc7739/mypy-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:301f1a8ccc7d79b542ee218b28bb49443a83e194eb3d10da63ff1649e5aa5d34", size = 13559524, upload-time = "2026-05-06T19:22:24.906Z" }, - { url = "https://files.pythonhosted.org/packages/af/a6/d7bb54fde1770f0484e5fbdbdce37a41e95ed0a1cd493ec60ead111e356c/mypy-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdf4ef489d44ce350bac3fd699907834e551d4c934e9cc862ef201215ab1558d", size = 13936018, upload-time = "2026-05-06T19:25:02.992Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ba/5be51316b91e6a6bf6e3a8adb3de500e7e1fb5bf9491743b8cbc81a34a2c/mypy-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cde2d0989f912fc850890f727d0d76495e7a6c5bdd9912a1efdb64952b4398d", size = 14910712, upload-time = "2026-05-06T19:25:21.83Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/e2c8c3b373e20ebfb66e6c83a99027fd67df4ec43b08879f74e822d2dc4c/mypy-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdf05693c231a14fe37dbfce192a3a1372c26a833af4a80f550547742952e719", size = 15141499, upload-time = "2026-05-06T19:20:50.924Z" }, - { url = "https://files.pythonhosted.org/packages/12/36/07756f933e00416d912e35878cfcf89a593a3350a885691c0bb85ae0226a/mypy-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:73aee2da33a2237e66cbe84a94780e53599847e86bb3aa7b93e405e8cd9905f2", size = 11240511, upload-time = "2026-05-06T19:21:32.39Z" }, - { url = "https://files.pythonhosted.org/packages/70/05/79ac1f20f2397353f3845f7b8bb5d8006cda7c8ef9092f04f9de3c6135f2/mypy-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:1f6dcd8f39971f41edab2728c877c4ac8b50ad3c387ff2770423b79a05d23910", size = 10149336, upload-time = "2026-05-06T19:22:08.383Z" }, - { url = "https://files.pythonhosted.org/packages/53/e0/0db84e0ebbad6e99e566c68e4b465784f2a2294f7719e8db9d509ef23087/mypy-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a04e980b9275c76159da66c6e1723c7798306f9802b31bdaf9358d0c84030ce8", size = 15797362, upload-time = "2026-05-06T19:22:00.835Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a4/14cc0768164dd53bec48aa41a20270b18df9bf72aa5054278bf133608315/mypy-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:33f9cf4825469b2bc73c53ba55f6d9a9b4cdb60f9e6e228745581520f29b8771", size = 14635914, upload-time = "2026-05-06T19:23:43.675Z" }, - { url = "https://files.pythonhosted.org/packages/08/48/d866a3e23b4dc5974c77d9cf65a435bf22de01a84dd4620917950e233960/mypy-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191675c3c7dc2a5c7722a035a6909c277f14046c5e4e02aa5fbf65f8524f08ad", size = 15270866, upload-time = "2026-05-06T19:22:34.756Z" }, - { url = "https://files.pythonhosted.org/packages/71/eb/de9ef94958eb2078a6b908ceb247757dc384d3a238d3bd6ed7d81de5eaf8/mypy-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3d26c4321a3b06fc9f04c741e0733af693f82d823f8e64e47b2e63b7f19fa84", size = 16093131, upload-time = "2026-05-06T19:23:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/ad/07/0ab2c1a9d26e90942612724cbd5788f16b7810c5dd39bfcf79286c6c4524/mypy-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bbcbc4d5917ca6ce12de70e051de7f533e3bf92d548b41a38a2232a6fe356525", size = 16330685, upload-time = "2026-05-06T19:21:42.037Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/46f85d1371a5be642dad263828118ae1efd536d91d8bd2000c68acff3920/mypy-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:dbc6ba6d40572ae49268531565793a8f07eac7fc65ad76d482c9b4c8765b6043", size = 12752017, upload-time = "2026-05-06T19:22:44.002Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e6/94ca48800cac19eb28a58188a768aaec0d16cac0f373915f073058ab0855/mypy-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:77926029dfcb7e1a3ecb0acb2ddbb24ca36be03f7d623e1759ad5376be8f6c01", size = 10527097, upload-time = "2026-05-06T19:20:58.973Z" }, - { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/71/d351dca3e9b30da2328ee9d445c88b8388072808ebfbc49eb69d30b67749/mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc", size = 14778792, upload-time = "2026-05-11T18:36:23.605Z" }, + { url = "https://files.pythonhosted.org/packages/2f/45/7d51594b644c17c0bcf74ed8cd5fc33b324276d708e8506f220b70dab9d9/mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849", size = 13645739, upload-time = "2026-05-11T18:37:22.752Z" }, + { url = "https://files.pythonhosted.org/packages/65/01/455c31b170e9468265074840bf18863a8482a24103fdaabe4e199392aa5f/mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd", size = 14074199, upload-time = "2026-05-11T18:35:09.292Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/93093f0b29a9e982deafde698f740a2eb2e05886e79ccf0594c7fd5413a3/mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166", size = 14953128, upload-time = "2026-05-11T18:31:57.678Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/a196f5331d96170ad3d28f144d2aba690d4b2911381f68d51e489c7ab82a/mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8", size = 15249378, upload-time = "2026-05-11T18:33:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/94d321cc12da9f71341ac0c270efbed5c725750c7b4c334d957de9a087d9/mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8", size = 11060994, upload-time = "2026-05-11T18:33:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/e1/62/0c27ca55219a7c764a7fb88c7bb2b7b2f9780ade8bbf16bc8ed8400eef6b/mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e", size = 9976743, upload-time = "2026-05-11T18:31:25.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -1084,7 +1086,7 @@ version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, - { name = "coverage", version = "7.14.0", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "coverage", version = "7.14.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, { name = "pluggy" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -1144,45 +1146,45 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]] name = "snowballstemmer" -version = "3.0.1" +version = "3.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -1480,14 +1482,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.3" +version = "4.68.1" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/06/b3/36c8ecf72e8925200671613332db156d84b99b3aee742a41c1938ebb0808/tqdm-4.68.1.tar.gz", hash = "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", size = 171236, upload-time = "2026-06-05T17:23:15.267Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/47/aa/218a0eb34de1f753c83e4d0d1c8e7c4cef27f20dcb8342e024f63a80dc86/tqdm-4.68.1-py3-none-any.whl", hash = "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8", size = 78354, upload-time = "2026-06-05T17:23:13.654Z" }, ] [[package]] @@ -1504,7 +1506,7 @@ wheels = [ [[package]] name = "types-psutil" -version = "7.2.2.20260508" +version = "7.2.2.20260518" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -1512,9 +1514,9 @@ resolution-markers = [ "python_full_version == '3.11.*'", "python_full_version == '3.10.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/70/cc/5ac56357b08655ff93106d6391b72d50c47416a044041312550bcd806827/types_psutil-7.2.2.20260508.tar.gz", hash = "sha256:8cfd8339f5e898570f80486423e65d87558d89d0181bf723d20ac5e778fe218e", size = 26575, upload-time = "2026-05-08T04:46:48.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f1/6901857281d4e8d792492e1495eef6f4f01318a3b6a066486d81000a4511/types_psutil-7.2.2.20260518.tar.gz", hash = "sha256:9f825f631463a5b4d26f19f63aebc9ec25f01140d655026f3ad8a67841f9b331", size = 26660, upload-time = "2026-05-18T06:05:09.389Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/44/4467583df75313c28abaadfab12f102fba9db7285323e75601acf936d996/types_psutil-7.2.2.20260508-py3-none-any.whl", hash = "sha256:b142452e0953f2d07dbdbb98d81f3a629f5906cc2d94bb7e34da0fba55fbab4a", size = 32777, upload-time = "2026-05-08T04:46:46.972Z" }, + { url = "https://files.pythonhosted.org/packages/cd/eb/f726339668879819599c74c2e1f0cab760912a4159046942bdae2ad37bd6/types_psutil-7.2.2.20260518-py3-none-any.whl", hash = "sha256:6a3d697665754a60d7b5a41d5a2cff12b53f5e0676d77810cd28ba5e14cb4049", size = 32820, upload-time = "2026-05-18T06:05:08.321Z" }, ] [[package]] @@ -1534,7 +1536,7 @@ wheels = [ [[package]] name = "types-requests" -version = "2.33.0.20260513" +version = "2.33.0.20260518" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -1545,9 +1547,9 @@ resolution-markers = [ dependencies = [ { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/f7/3228dd3794941bcb92ca6ca2045a6671a828ec0b47becbef23310bc45559/types_requests-2.33.0.20260513.tar.gz", hash = "sha256:bd845450e954e751373d5d33526742592f298808a3ee3bda7e858e46b839b57f", size = 24714, upload-time = "2026-05-13T05:39:23.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/f5/233a78be8367a9888de718f002fb27b1ea4be39471cd88aedeafceed872e/types_requests-2.33.0.20260513-py3-none-any.whl", hash = "sha256:d5a965f9d18b6e06b72039a69565de9027e58f36a7f709857da747fbe7521122", size = 21390, upload-time = "2026-05-13T05:39:22.262Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, ] [[package]] @@ -1564,7 +1566,7 @@ wheels = [ [[package]] name = "types-toml" -version = "0.10.8.20260508" +version = "0.10.8.20260518" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -1572,9 +1574,9 @@ resolution-markers = [ "python_full_version == '3.11.*'", "python_full_version == '3.10.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/95/3d/df34d25e9d1fbbda6af6033bb13cf228a11d838d11bea93a05840d43d98d/types_toml-0.10.8.20260508.tar.gz", hash = "sha256:93aaa72365bd9bc8c2ce1ca3502b0470d8d428376bada020e1a4956b8b82fdda", size = 9380, upload-time = "2026-05-08T04:46:58.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/11/6ece999e91f2ccb848ab4420f3f4816e78ac0541f739e6864affdaaa5737/types_toml-0.10.8.20260518.tar.gz", hash = "sha256:80e10facd24fdeda9d5c672187d72be3ac284843788d67f5aae59e3e016db6fe", size = 9419, upload-time = "2026-05-18T06:02:16.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/e1/cb6b12945b2d0ae8f93bfab320be81a8f5bb8b6e1384744ca05c9a6909c0/types_toml-0.10.8.20260508-py3-none-any.whl", hash = "sha256:144d615419051493ab1e5e71b6477f7c56fadc78f0856407775e75c8e2916140", size = 9662, upload-time = "2026-05-08T04:46:57.709Z" }, + { url = "https://files.pythonhosted.org/packages/91/25/489751806bf5c95e4007f8e17409199c54d31e49ffbea07c5729b1286c8e/types_toml-0.10.8.20260518-py3-none-any.whl", hash = "sha256:0e564ab05f6fde62a315b3b5a9b6624fda569399795d30a37e64705a70459303", size = 9669, upload-time = "2026-05-18T06:02:15.86Z" }, ] [[package]] @@ -1594,7 +1596,7 @@ wheels = [ [[package]] name = "types-tqdm" -version = "4.67.3.20260508" +version = "4.68.0.20260608" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -1603,11 +1605,11 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "types-requests", version = "2.33.0.20260513", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "types-requests", version = "2.33.0.20260518", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/d9/add71c78db72e934747f7467ffe7b8fa9f3e9fb38ffa5377d5dd390ac036/types_tqdm-4.67.3.20260508.tar.gz", hash = "sha256:9acfdd179bdf5cc81f7ce7353b5b85eb92b16667bba89ec6c187b5e7ce617986", size = 18141, upload-time = "2026-05-08T04:52:34.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/e0/3facccb1ff69970c73fca7a8028286c233d4c1312c475a65fb3d896f56d9/types_tqdm-4.68.0.20260608.tar.gz", hash = "sha256:e1dfddf8770fbc30ecaf95ae57c286397831235064308f7dfc2b1d6684a76107", size = 18470, upload-time = "2026-06-08T06:26:06.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/64/e66c98e951deb5985fbff40a11ba0a1f0528505e0734fa6c39534fc0b113/types_tqdm-4.67.3.20260508-py3-none-any.whl", hash = "sha256:0440759cc861a90c1cc98870f2c15ac633c0b6b14651dcafb83f98ab83bad0f4", size = 24546, upload-time = "2026-05-08T04:52:33.995Z" }, + { url = "https://files.pythonhosted.org/packages/53/e8/61d95bfd49d1609fb8e8c5e06f4a094183411988a6f448873f5de6602499/types_tqdm-4.68.0.20260608-py3-none-any.whl", hash = "sha256:450a6e7e9e9b604928968927c414b32970e40091213c4180e1ed470905b13eff", size = 24858, upload-time = "2026-06-08T06:26:05.741Z" }, ] [[package]]