diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f9d92a5..3c0af2e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -50,7 +50,7 @@ before_script: - pip install --upgrade pip - pip install poetry==2.1.2 - poetry --version - - poetry install -vv -E keyring + - poetry install -vv -E keyring --without async-dev # stage: check ---------------------- @@ -173,6 +173,9 @@ package: USE_MOCK_SERVER: "use mock server" - DOCKER_IMAGE: "python:3.13" USE_MOCK_SERVER: "use mock server" + - DOCKER_IMAGE: "python:3.13" + USE_MOCK_SERVER: "use mock server" + WITH_ASYNC: "true" image: ${DOCKER_IMAGE} script: - > @@ -180,6 +183,11 @@ package: echo "Running poetry add ${EXTRA_POETRY_ADD_ARGUMENT}" poetry add ${EXTRA_POETRY_ADD_ARGUMENT} fi + - > + if [[ ! -z "${WITH_ASYNC}" ]]; then + echo "Installing async extra" + poetry install --with async-dev -E async + fi - > if [[ ! -z "${USE_MOCK_SERVER}" ]]; then echo "Using mock server" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f30a7e..6302541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `DeepLClient.set_user_agent(string)`: new fluent method to override the `User-Agent` + header for a specific client instance, replacing the deprecated + `deepl.http_client.user_agent` global. + +### Changed +- `deepl.http_client.max_network_retries` and `deepl.http_client.min_connection_timeout` + are now read once when `DeepLClient` is constructed and baked into its `RetryConfig`. + Previously they were consulted on every request; now they must be set **before** + creating the client — values set afterwards are silently ignored. +- `deepl.http_client.user_agent` is now read once at `DeepLClient` construction time. + Previously it was read on every request; now it must be set **before** creating the + client — values set afterwards are silently ignored. +- Each HTTP client now contributes its library version string to the `User-Agent` header + (e.g. `requests/2.32.5`). Custom HTTP client implementations should add an + `http_library_info: str` attribute to conform to the updated `HttpClientProtocol`. ## [1.30.0] - 2026-04-09 ### Added @@ -36,6 +52,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated formality tests to accept either formal or informal output when using default formality, since the default formality is automatic. +### Deprecated +- Deprecated `deepl.http_client` global variables: + - `deepl.http_client.max_network_retries`: use `RetryConfig` instead. + - `deepl.http_client.min_connection_timeout`: use `RetryConfig` instead. + - `deepl.http_client.user_agent`: use `DeepLClient.set_user_agent()` instead. + ## [1.28.0] - 2026-02-05 ### Changed - Updated three tests to be less flakey and reflect new behavior regarding the model type. diff --git a/README.md b/README.md index 187fe98..e80ec36 100644 --- a/README.md +++ b/README.md @@ -834,9 +834,9 @@ deepl_client = deepl.DeepLClient(...).set_app_info("sample_python_plugin", "1.0. ``` This information is passed along when the library makes calls to the DeepL API. -Both name and version are required. Please note that setting the `User-Agent` header -via `deepl.http_client.user_agent` will override this setting, if you need to use this, -please manually identify your Application in the `User-Agent` header. +Both name and version are required. Please note that calling `set_user_agent()` +will override this setting, if you need to use this, please manually identify +your Application in the custom user agent string. ### Exceptions @@ -901,20 +901,27 @@ option, see the [documentation for requests][requests-verify-ssl-docs]. #### Configure automatic retries This SDK will automatically retry failed HTTP requests (if the failures could -be transient, e.g. a HTTP 429 status code). This behaviour can be configured -in `http_client.py`, for example by default the number of retries is 5. This -can be changed to 3 as follows: +be transient, e.g. a HTTP 429 status code). The recommended way to configure +this behaviour is via `RetryConfig`: ```python import deepl -deepl.http_client.max_network_retries = 3 -c = deepl.DeepLClient(...) -c.translate_text(...) +retry_config = deepl.RetryConfig(max_retries=3, min_connection_timeout=5.0) +deepl_client = deepl.DeepLClient(..., retry_config=retry_config) +deepl_client.translate_text(...) ``` -You can configure the timeout `min_connection_timeout` the same way, as well -as set a custom `user_agent`, see the next section. +`RetryConfig` accepts the following parameters: +- `max_retries` (default: `5`): maximum number of retry attempts. +- `min_connection_timeout` (default: `10.0`): connection timeout in seconds. + +> **Deprecated:** The `deepl.http_client.max_network_retries` and +> `deepl.http_client.min_connection_timeout` module-level variables are +> deprecated and will be removed in a future version. When set, they are +> read once at `DeepLClient` construction time and override the corresponding +> `RetryConfig` fields. **They have no effect if set after the client is +> constructed.** #### Anonymous platform information @@ -924,13 +931,19 @@ By default, we send some basic information about the platform the client library deepl_client = deepl.DeepLClient(..., send_platform_info=False) ``` -You can also customize the `user_agent` by setting its value explicitly before constructing your `deepl.DeepLClient` object. +You can also replace the entire `User-Agent` header with a custom string using +`set_user_agent()`: ```python -deepl.http_client.user_agent = 'my custom user agent' -deepl_client = deepl.DeepLClient(os.environ["DEEPL_AUTH_KEY"]) +deepl_client = deepl.DeepLClient(os.environ["DEEPL_AUTH_KEY"]).set_user_agent( + "my custom user agent" +) ``` +> **Deprecated:** Setting `deepl.http_client.user_agent` before constructing +> the client is deprecated and will be removed in a future version. Use +> `set_user_agent()` instead. + ## Command Line Interface The library can be run on the command line supporting all API functions. Use the diff --git a/deepl/__init__.py b/deepl/__init__.py index dff9a4c..0587909 100644 --- a/deepl/__init__.py +++ b/deepl/__init__.py @@ -6,7 +6,18 @@ __author__ = "DeepL SE " -from .deepl_client import DeepLClient +from .deepl_client import DeepLClient # noqa +from .requests_client import RequestsClient # noqa +from .retry_config import RetryConfig # noqa +from ._http_types import SslConfig # noqa + +try: + from .aiohttp_client import AioHttpClient # noqa + from .deepl_client_async import DeepLClientAsync # noqa + + _have_async = True +except ImportError: + _have_async = False from .exceptions import ( # noqa AuthorizationException, @@ -45,11 +56,15 @@ "__version__", "__author__", "DeepLClient", + "RequestsClient", + "RetryConfig", + "SslConfig", "DocumentHandle", "DocumentStatus", "Formality", "GlossaryInfo", "Language", + "ModelType", "SplitSentences", "TextResult", "Translator", @@ -68,3 +83,6 @@ "convert_dict_to_tsv", "validate_glossary_term", ] + +if _have_async: + __all__ += ["DeepLClientAsync", "AioHttpClient"] diff --git a/deepl/_backoff_timer.py b/deepl/_backoff_timer.py new file mode 100644 index 0000000..e616a3a --- /dev/null +++ b/deepl/_backoff_timer.py @@ -0,0 +1,83 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import random +import time +from typing import Callable + +from .retry_config import RetryConfig + + +class BackoffTimer: + """Exponential-backoff timer. Pure logic — no sleeping. + + Based on the gRPC Connection Backoff Protocol: + https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md + + Usage in a retry loop:: + + timer = BackoffTimer(config) + while True: + try: + result = attempt() + return result + except RetryableError: + if timer.get_num_retries() >= config.max_retries: + raise + sleep_fn(timer.get_time_until_deadline()) + timer.advance() + + :param config: RetryConfig controlling backoff parameters. + :param time_fn: Clock function (default time.time). Override in tests to + control perceived time without actual sleeping. + + .. note:: + The first backoff deadline is set at construction time, not after the + first failure. If the initial attempt itself is slow (e.g. it times out + after many seconds), the first retry sleep may be shorter than + ``backoff_initial``. This is consistent with the gRPC backoff protocol. + """ + + def __init__( + self, + config: RetryConfig, + time_fn: Callable[[], float] = time.time, + ) -> None: + self._config = config + self._time_fn = time_fn + self._num_retries = 0 + self._backoff = config.backoff_initial + self._deadline = time_fn() + self._backoff + + def get_num_retries(self) -> int: + """Return the number of retries that have been recorded so far.""" + return self._num_retries + + def get_time_until_deadline(self) -> float: + """Return seconds until the current backoff deadline (≥ 0).""" + return max(self._deadline - self._time_fn(), 0.0) + + def get_timeout(self, min_timeout: float) -> float: + """Return the connection timeout to use for the current attempt. + + Returns ``max(time_until_deadline, min_timeout)`` so that later + retries — which have longer backoff periods — also receive + proportionally longer connection timeouts. + """ + return max(self.get_time_until_deadline(), min_timeout) + + def advance(self) -> None: + """Record a completed retry and compute the next backoff deadline. + + Call this *after* sleeping + (i.e. after sleep_fn(get_time_until_deadline())). + """ + self._backoff = min( + self._backoff * self._config.backoff_multiplier, + self._config.backoff_max, + ) + self._deadline = self._time_fn() + self._backoff * ( + 1 + self._config.backoff_jitter * random.uniform(-1, 1) + ) + self._num_retries += 1 diff --git a/deepl/_client_base.py b/deepl/_client_base.py new file mode 100644 index 0000000..6cbb1bb --- /dev/null +++ b/deepl/_client_base.py @@ -0,0 +1,286 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import http +import http.client +import json as json_module +import platform +import traceback +from typing import Any, Dict, Optional, Union + +from . import util, version +from .api_data import ( + GlossaryInfo, + Language, + MultilingualGlossaryInfo, + StyleRuleInfo, +) +from .exceptions import ( + AuthorizationException, + DeepLException, + DocumentNotReadyException, + GlossaryNotFoundException, + QuotaExceededException, + TooManyRequestsException, +) +from ._http_types import HttpResponse + + +def _check_valid_languages( + source_lang: Optional[str], target_lang: str +) -> None: + if target_lang == "EN": + raise DeepLException( + 'target_lang="EN" is deprecated, ' + 'please use "EN-GB" or "EN-US" instead.' + ) + elif target_lang == "PT": + raise DeepLException( + 'target_lang="PT" is deprecated, ' + 'please use "PT-PT" or "PT-BR" instead.' + ) + + +def _check_language_and_formality( + source_lang: Optional[Union[str, Any]], + target_lang: Union[str, Any], + formality: Optional[Union[str, Any]] = None, + glossary: Optional[Union[str, Any]] = None, + style_rule: Optional[Union[str, Any]] = None, +) -> Dict[str, Any]: + target_lang = str(target_lang).upper() + if source_lang is not None: + source_lang = str(source_lang).upper() + if glossary is not None and source_lang is None: + raise ValueError("source_lang is required if using a glossary") + if isinstance(glossary, GlossaryInfo): + if ( + Language.remove_regional_variant(target_lang) + != glossary.target_lang + or source_lang != glossary.source_lang + ): + raise ValueError("source_lang and target_lang must match glossary") + if isinstance(glossary, MultilingualGlossaryInfo): + target_lang_code = Language.remove_regional_variant(target_lang) + if not any( + d.target_lang == target_lang_code and d.source_lang == source_lang + for d in glossary.dictionaries + ): + raise ValueError( + "must have a glossary with a dictionary for the given " + "source_lang and target_lang" + ) + if isinstance(style_rule, StyleRuleInfo): + if ( + Language.remove_regional_variant(target_lang) + != style_rule.language.upper() + ): + raise ValueError("target_lang must match style rule language") + _check_valid_languages(source_lang, target_lang) + request_data: Dict[str, Any] = {"target_lang": target_lang} + if source_lang is not None: + request_data["source_lang"] = source_lang + if formality is not None: + request_data["formality"] = str(formality).lower() + if isinstance(glossary, (GlossaryInfo, MultilingualGlossaryInfo)): + request_data["glossary_id"] = glossary.glossary_id + elif glossary is not None: + request_data["glossary_id"] = glossary + if isinstance(style_rule, StyleRuleInfo): + request_data["style_id"] = style_rule.style_id + elif style_rule is not None: + request_data["style_id"] = style_rule + return request_data + + +def _generate_user_agent( + send_platform_info: bool, + app_info_name: Optional[str], + app_info_version: Optional[str], + http_library_info: str = "", +) -> str: + """Build the User-Agent header value.""" + library_info_str = f"deepl-python/{version.VERSION}" + if send_platform_info: + try: + library_info_str += ( + f" ({platform.platform()}) " + f"python/{platform.python_version()}" + ) + if http_library_info: + library_info_str += f" {http_library_info}" + except Exception: + util.log_info( + "Exception when querying platform information:\n" + + traceback.format_exc() + ) + if app_info_name and app_info_version: + library_info_str += f" {app_info_name}/{app_info_version}" + return library_info_str + + +class _ClientBase: + """Shared non-I/O logic for DeepLClient and DeepLClientAsync. + + Handles auth, URL resolution, language/formality validation, and + response error mapping. Contains no network I/O and no close(). + """ + + _DEEPL_SERVER_URL = "https://api.deepl.com" + _DEEPL_SERVER_URL_FREE = "https://api-free.deepl.com" + _HTTP_STATUS_QUOTA_EXCEEDED = 456 + + def __init__( + self, + auth_key: str, + server_url: Optional[str] = None, + send_platform_info: bool = True, + ) -> None: + if not auth_key: + raise ValueError("auth_key must not be empty") + + self._auth_key = auth_key + if server_url is None: + server_url = ( + self._DEEPL_SERVER_URL_FREE + if util.auth_key_is_free_account(auth_key) + else self._DEEPL_SERVER_URL + ) + if not server_url.endswith("/"): + server_url += "/" + self._server_url = server_url + self._send_platform_info = send_platform_info + self._app_info_name: Optional[str] = None + self._app_info_version: Optional[str] = None + self._http_library_info: str = "" + self._custom_user_agent: Optional[str] = None + + @property + def server_url(self) -> str: + return self._server_url.rstrip("/") + + def set_app_info( + self, app_info_name: str, app_info_version: str + ) -> "_ClientBase": + """Set app name and version to be included in the User-Agent header.""" + self._app_info_name = app_info_name + self._app_info_version = app_info_version + return self + + def set_user_agent(self, user_agent: str) -> "_ClientBase": + """Override the entire User-Agent header with a custom string. + + The app info set via :meth:`set_app_info` is still appended if set. + """ + self._custom_user_agent = user_agent + return self + + def _make_auth_headers(self) -> Dict[str, str]: + """Return headers that must be added to every request.""" + if self._custom_user_agent is not None: + ua = self._custom_user_agent + if self._app_info_name and self._app_info_version: + ua = f"{ua} {self._app_info_name}/{self._app_info_version}" + else: + ua = _generate_user_agent( + self._send_platform_info, + self._app_info_name, + self._app_info_version, + self._http_library_info, + ) + return { + "Authorization": f"DeepL-Auth-Key {self._auth_key}", + "User-Agent": ua, + } + + def _raise_for_status( + self, + response: HttpResponse, + glossary: bool = False, + downloading_document: bool = False, + ) -> None: + """Raise an appropriate exception for non-2xx/3xx responses. + + Parses the JSON body for a human-readable message if available. + Does nothing for 2xx/3xx responses. + """ + status_code = response.status_code + + json_data: Any = None + try: + json_data = json_module.loads(response.content) + except Exception: + pass + + message = "" + if isinstance(json_data, dict): + if "message" in json_data: + message += ", message: " + json_data["message"] + if "detail" in json_data: + message += ", detail: " + json_data["detail"] + + if 200 <= status_code < 400: + return + elif status_code == http.HTTPStatus.FORBIDDEN: + raise AuthorizationException( + f"Authorization failure, check auth_key{message}", + http_status_code=status_code, + ) + elif status_code == self._HTTP_STATUS_QUOTA_EXCEEDED: + raise QuotaExceededException( + f"Quota for this billing period has been exceeded{message}", + http_status_code=status_code, + ) + elif status_code == http.HTTPStatus.NOT_FOUND: + if glossary: + raise GlossaryNotFoundException( + f"Glossary not found{message}", + http_status_code=status_code, + ) + raise DeepLException( + f"Not found{message}", + http_status_code=status_code, + ) + elif status_code == http.HTTPStatus.BAD_REQUEST: + raise DeepLException( + f"Bad request{message}", http_status_code=status_code + ) + elif status_code == http.HTTPStatus.TOO_MANY_REQUESTS: + raise TooManyRequestsException( + "Too many requests, DeepL servers are currently experiencing " + f"high load{message}", + should_retry=True, + http_status_code=status_code, + ) + elif status_code == http.HTTPStatus.SERVICE_UNAVAILABLE: + if downloading_document: + raise DocumentNotReadyException( + f"Document not ready{message}", + should_retry=True, + http_status_code=status_code, + ) + else: + raise DeepLException( + f"Service unavailable{message}", + should_retry=True, + http_status_code=status_code, + ) + elif status_code >= 500: + status_name = http.client.responses.get(status_code, "Unknown") + content_str = response.content.decode("utf-8", errors="replace") + raise DeepLException( + f"Unexpected status code: {status_code} {status_name}, " + f"content: {content_str}.", + should_retry=True, + http_status_code=status_code, + ) + else: + status_name = http.client.responses.get(status_code, "Unknown") + content_str = response.content.decode("utf-8", errors="replace") + raise DeepLException( + f"Unexpected status code: {status_code} {status_name}, " + f"content: {content_str}.", + should_retry=False, + http_status_code=status_code, + ) diff --git a/deepl/_http_types.py b/deepl/_http_types.py new file mode 100644 index 0000000..0df7059 --- /dev/null +++ b/deepl/_http_types.py @@ -0,0 +1,127 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import io +from dataclasses import dataclass, field +from typing import ( + AsyncIterator, + BinaryIO, + Callable, + Dict, + Iterator, + Optional, + Union, +) + +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol # type: ignore[assignment] + +# SSL verification config, compatible with requests' verify= parameter. +# None/True = default verification, False = disable, str = CA bundle path. +SslConfig = Union[bool, str, None] + + +@dataclass +class MultipartBody: + """Multipart form-data body for document upload. + + file_factory is called fresh on each retry attempt so the stream is + always at position 0. + """ + + fields: Dict[str, str] + file_factory: Callable[[], BinaryIO] + file_name: str + file_content_type: str + + +@dataclass +class HttpRequest: + """Transport-agnostic HTTP request. + + body and multipart are mutually exclusive: + - body: pre-serialised bytes (JSON, TSV, …) + - multipart: document upload via MultipartBody + + timeout is set by DeepLClient at send time from RetryConfig (possibly + overridden by http_client module globals) and is always present. + """ + + method: str + url: str + headers: Dict[str, str] = field(default_factory=dict) + body: Optional[bytes] = None + multipart: Optional[MultipartBody] = None + timeout: Optional[float] = None + + def __post_init__(self) -> None: + if self.body is not None and self.multipart is not None: + raise ValueError("body and multipart are mutually exclusive") + + +@dataclass +class HttpResponse: + """Fully-buffered HTTP response.""" + + status_code: int + headers: Dict[str, str] + content: bytes + + +class StreamingHttpResponse(Protocol): + """Protocol for streaming HTTP responses (document download only). + + NOT @runtime_checkable — this is a return type, not user-supplied. + """ + + status_code: int + headers: Dict[str, str] + + def iter_content(self, chunk_size: int = 65536) -> Iterator[bytes]: ... + + +class AsyncStreamingHttpResponse(Protocol): + """Protocol for async streaming HTTP responses. + + NOT @runtime_checkable — this is a return type, not user-supplied. + Implementations should also be usable as async context managers + (``__aenter__`` / ``__aexit__``) so callers can release the + underlying connection deterministically without an explicit + ``await close()``. + """ + + status_code: int + headers: Dict[str, str] + + def aiter_content( + self, chunk_size: int = 65536 + ) -> AsyncIterator[bytes]: ... + + async def close(self) -> None: ... + + async def __aenter__(self) -> "AsyncStreamingHttpResponse": ... + + async def __aexit__(self, *args: object) -> None: ... + + +def make_file_factory( + input_document: Union[BinaryIO, bytes, str], +) -> Callable[[], BinaryIO]: + """Return a factory that produces a fresh BinaryIO on each call. + + Used for retryable document uploads: each retry gets a seek(0) stream. + """ + if isinstance(input_document, bytes): + data = input_document + return lambda: io.BytesIO(data) + if isinstance(input_document, str): + data = input_document.encode("utf-8") + return lambda: io.BytesIO(data) + # File-like: read into memory once so retries work reliably. + data = input_document.read() + if isinstance(data, str): + data = data.encode("utf-8") + return lambda: io.BytesIO(data) diff --git a/deepl/_methods/__init__.py b/deepl/_methods/__init__.py new file mode 100644 index 0000000..e5e4d74 --- /dev/null +++ b/deepl/_methods/__init__.py @@ -0,0 +1,75 @@ +# Copyright 2026 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +# Re-export all builder/parser functions (private, underscore-prefixed). +# DeepLClient and DeepLClientAsync import from here. + +from ._translate import ( # noqa + _build_list_translation_memories_request, + _build_translate_text_request, + _parse_list_translation_memories_response, + _parse_translate_text_response, +) +from ._document import ( # noqa + _build_document_upload_request, + _parse_document_upload_response, + _build_document_status_request, + _parse_document_status_response, + _build_document_download_request, +) +from ._glossary import ( # noqa + _build_create_glossary_request, + _parse_create_glossary_response, + _build_get_glossary_request, + _parse_get_glossary_response, + _build_list_glossaries_request, + _parse_list_glossaries_response, + _build_get_glossary_entries_request, + _parse_get_glossary_entries_response, + _build_delete_glossary_request, + _build_create_multilingual_glossary_request, + _parse_multilingual_glossary_response, + _build_update_multilingual_glossary_name_request, + _build_update_multilingual_glossary_dict_request, + _build_replace_multilingual_glossary_dict_request, + _parse_multilingual_glossary_dict_response, + _build_get_multilingual_glossary_request, + _build_list_multilingual_glossaries_request, + _parse_list_multilingual_glossaries_response, + _build_get_multilingual_glossary_entries_request, + _parse_multilingual_glossary_entries_response, + _build_delete_multilingual_glossary_request, + _build_delete_multilingual_glossary_dict_request, +) +from ._usage import ( # noqa + _build_get_usage_request, + _parse_get_usage_response, +) +from ._languages import ( # noqa + _build_get_source_languages_request, + _build_get_target_languages_request, + _parse_get_source_languages_response, + _parse_get_target_languages_response, + _build_get_glossary_languages_request, + _parse_get_glossary_languages_response, +) +from ._write import ( # noqa + _build_rephrase_text_request, + _parse_rephrase_text_response, +) +from ._style_rules import ( # noqa + _build_get_style_rules_request, + _parse_get_style_rules_response, + _build_create_style_rule_request, + _parse_style_rule_response, + _build_get_style_rule_request, + _build_update_style_rule_name_request, + _build_delete_style_rule_request, + _build_update_style_rule_configured_rules_request, + _build_create_style_rule_custom_instruction_request, + _parse_custom_instruction_response, + _build_get_style_rule_custom_instruction_request, + _build_update_style_rule_custom_instruction_request, + _build_delete_style_rule_custom_instruction_request, +) diff --git a/deepl/_methods/_document.py b/deepl/_methods/_document.py new file mode 100644 index 0000000..2ef7af7 --- /dev/null +++ b/deepl/_methods/_document.py @@ -0,0 +1,142 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import pathlib +import urllib.parse +from typing import Any, BinaryIO, Dict, Optional, TextIO, Union + +from ..api_data import ( + DocumentHandle, + DocumentStatus, + Formality, + GlossaryInfo, + MultilingualGlossaryInfo, +) +from .._client_base import _check_language_and_formality +from .._http_types import ( + HttpRequest, + HttpResponse, + MultipartBody, + make_file_factory, +) +from ..exceptions import DocumentTranslationException + + +def _build_document_upload_request( + server_url: str, + input_document: Union[TextIO, BinaryIO, str, bytes, Any], + *, + target_lang: str, + source_lang: Optional[str] = None, + formality: Union[str, Formality, None] = None, + glossary: Union[str, GlossaryInfo, MultilingualGlossaryInfo, None] = None, + filename: Optional[str] = None, + output_format: Optional[str] = None, + extra_body_parameters: Optional[dict] = None, +) -> HttpRequest: + """Build a POST /v2/document multipart upload request.""" + if isinstance(input_document, (str, bytes)) and filename is None: + raise ValueError( + "filename is required if uploading file content as string or bytes" + ) + + lang_fields = _check_language_and_formality( + source_lang, target_lang, formality, glossary + ) + fields: Dict[str, str] = {k: str(v) for k, v in lang_fields.items()} + if output_format: + fields["output_format"] = output_format + if extra_body_parameters: + fields.update({k: str(v) for k, v in extra_body_parameters.items()}) + + if filename is None: + file_name = getattr(input_document, "name", "document") + if isinstance(file_name, bytes): + file_name = file_name.decode("utf-8", errors="replace") + if isinstance(file_name, str): + file_name = pathlib.PurePath(file_name).name or "document" + else: + file_name = "document" + else: + file_name = filename + + return HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, "v2/document"), + multipart=MultipartBody( + fields=fields, + file_factory=make_file_factory( + input_document # type: ignore[arg-type] + ), + file_name=file_name, + file_content_type="application/octet-stream", + ), + ) + + +def _parse_document_upload_response(response: HttpResponse) -> DocumentHandle: + json_data = json_module.loads(response.content) if response.content else {} + if not isinstance(json_data, dict): + json_data = {} + return DocumentHandle( + json_data.get("document_id", ""), json_data.get("document_key", "") + ) + + +def _safe_id(id_str: str) -> str: + """URL-encode a document/glossary ID for safe path interpolation.""" + return urllib.parse.quote(id_str, safe="") + + +def _build_document_status_request( + server_url: str, handle: DocumentHandle +) -> HttpRequest: + """Build a POST /v2/document/{id} request.""" + body = json_module.dumps({"document_key": handle.document_key}).encode( + "utf-8" + ) + doc_id = _safe_id(handle.document_id) + return HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, f"v2/document/{doc_id}"), + headers={"Content-Type": "application/json"}, + body=body, + ) + + +def _parse_document_status_response( + response: HttpResponse, handle: DocumentHandle +) -> DocumentStatus: + json_data = json_module.loads(response.content) if response.content else {} + if not isinstance(json_data, dict): + json_data = {} + + status = json_data.get("status", None) + if not status: + raise DocumentTranslationException( + "Querying document status gave an empty response", handle + ) + seconds_remaining = json_data.get("seconds_remaining", None) + billed_characters = json_data.get("billed_characters", None) + error_message = json_data.get("error_message", None) + return DocumentStatus( + status, seconds_remaining, billed_characters, error_message + ) + + +def _build_document_download_request( + server_url: str, handle: DocumentHandle +) -> HttpRequest: + """Build a POST /v2/document/{id}/result streaming request.""" + body = json_module.dumps({"document_key": handle.document_key}).encode( + "utf-8" + ) + doc_id = _safe_id(handle.document_id) + return HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, f"v2/document/{doc_id}/result"), + headers={"Content-Type": "application/json"}, + body=body, + ) diff --git a/deepl/_methods/_glossary.py b/deepl/_methods/_glossary.py new file mode 100644 index 0000000..ab945c5 --- /dev/null +++ b/deepl/_methods/_glossary.py @@ -0,0 +1,368 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import urllib.parse +from typing import Dict, List, Optional, Union + +from ..api_data import ( + GlossaryInfo, + Language, + MultilingualGlossaryDictionaryEntries, + MultilingualGlossaryDictionaryEntriesResponse, + MultilingualGlossaryDictionaryInfo, + MultilingualGlossaryInfo, +) +from .._http_types import HttpRequest, HttpResponse +from .. import util + + +def _safe_id(glossary_id: str) -> str: + """URL-encode a glossary/document ID for safe path interpolation.""" + return urllib.parse.quote(glossary_id, safe="") + + +# --------------------------------------------------------------------------- +# Classic (v2) glossary operations +# --------------------------------------------------------------------------- + + +def _build_create_glossary_request( + server_url: str, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + entries_format: str, + entries: Union[str, bytes], +) -> HttpRequest: + source_lang_str = Language.remove_regional_variant(source_lang) + target_lang_str = Language.remove_regional_variant(target_lang) + if not name: + raise ValueError("glossary name must not be empty") + request_data: dict = { + "name": name, + "source_lang": source_lang_str, + "target_lang": target_lang_str, + "entries_format": entries_format, + "entries": entries if isinstance(entries, str) else entries.decode(), + } + body = json_module.dumps(request_data).encode("utf-8") + return HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, "v2/glossaries"), + headers={"Content-Type": "application/json"}, + body=body, + ) + + +def _parse_create_glossary_response(response: HttpResponse) -> GlossaryInfo: + json_data = json_module.loads(response.content) + return GlossaryInfo.from_json(json_data) + + +def _build_get_glossary_request( + server_url: str, glossary: Union[str, GlossaryInfo] +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, GlossaryInfo) + else glossary + ) + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, f"v2/glossaries/{glossary_id}"), + ) + + +def _parse_get_glossary_response(response: HttpResponse) -> GlossaryInfo: + json_data = json_module.loads(response.content) + return GlossaryInfo.from_json(json_data) + + +def _build_list_glossaries_request(server_url: str) -> HttpRequest: + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, "v2/glossaries"), + ) + + +def _parse_list_glossaries_response( + response: HttpResponse, +) -> List[GlossaryInfo]: + json_data = json_module.loads(response.content) + glossaries = ( + json_data.get("glossaries", []) if isinstance(json_data, dict) else [] + ) + return [GlossaryInfo.from_json(g) for g in glossaries] + + +def _build_get_glossary_entries_request( + server_url: str, glossary: Union[str, GlossaryInfo] +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, GlossaryInfo) + else glossary + ) + return HttpRequest( + method="GET", + url=urllib.parse.urljoin( + server_url, f"v2/glossaries/{glossary_id}/entries" + ), + headers={"Accept": "text/tab-separated-values"}, + ) + + +def _parse_get_glossary_entries_response(response: HttpResponse) -> dict: + content_str = response.content.decode("utf-8") + return util.convert_tsv_to_dict(content_str) + + +def _build_delete_glossary_request( + server_url: str, glossary: Union[str, GlossaryInfo] +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, GlossaryInfo) + else glossary + ) + return HttpRequest( + method="DELETE", + url=urllib.parse.urljoin(server_url, f"v2/glossaries/{glossary_id}"), + ) + + +# --------------------------------------------------------------------------- +# Multilingual (v3) glossary operations +# --------------------------------------------------------------------------- + + +def _build_create_multilingual_glossary_request( + server_url: str, + name: str, + glossary_dicts: List[MultilingualGlossaryDictionaryEntries], +) -> HttpRequest: + if not name: + raise ValueError("glossary name must not be empty") + req_dicts = [ + { + "source_lang": Language.remove_regional_variant(d.source_lang), + "target_lang": Language.remove_regional_variant(d.target_lang), + "entries": util.convert_dict_to_tsv(d.entries), + "entries_format": "tsv", + } + for d in glossary_dicts + ] + body = json_module.dumps({"name": name, "dictionaries": req_dicts}).encode( + "utf-8" + ) + return HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, "v3/glossaries"), + headers={"Content-Type": "application/json"}, + body=body, + ) + + +def _parse_multilingual_glossary_response( + response: HttpResponse, +) -> MultilingualGlossaryInfo: + json_data = json_module.loads(response.content) + return MultilingualGlossaryInfo.from_json(json_data) + + +def _build_update_multilingual_glossary_name_request( + server_url: str, glossary: Union[str, MultilingualGlossaryInfo], name: str +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + if not name: + raise ValueError("glossary name must not be empty") + if not glossary_id: + raise ValueError("glossary id must not be empty") + body = json_module.dumps({"name": name}).encode("utf-8") + return HttpRequest( + method="PATCH", + url=urllib.parse.urljoin(server_url, f"v3/glossaries/{glossary_id}"), + headers={"Content-Type": "application/json"}, + body=body, + ) + + +def _build_update_multilingual_glossary_dict_request( + server_url: str, + glossary: Union[str, MultilingualGlossaryInfo], + dictionaries: List[MultilingualGlossaryDictionaryEntries], +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + if not glossary_id: + raise ValueError("glossary id must not be empty") + req_dicts = [ + { + "source_lang": Language.remove_regional_variant(d.source_lang), + "target_lang": Language.remove_regional_variant(d.target_lang), + "entries": util.convert_dict_to_tsv(d.entries), + "entries_format": "tsv", + } + for d in dictionaries + ] + body = json_module.dumps({"dictionaries": req_dicts}).encode("utf-8") + return HttpRequest( + method="PATCH", + url=urllib.parse.urljoin(server_url, f"v3/glossaries/{glossary_id}"), + headers={"Content-Type": "application/json"}, + body=body, + ) + + +def _build_replace_multilingual_glossary_dict_request( + server_url: str, + glossary: Union[str, MultilingualGlossaryInfo], + source_lang: str, + target_lang: str, + entries: Dict[str, str], +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + if not glossary_id: + raise ValueError("glossary id must not be empty") + request_data = { + "source_lang": Language.remove_regional_variant(source_lang), + "target_lang": Language.remove_regional_variant(target_lang), + "entries": util.convert_dict_to_tsv(entries), + "entries_format": "tsv", + } + body = json_module.dumps(request_data).encode("utf-8") + return HttpRequest( + method="PUT", + url=urllib.parse.urljoin( + server_url, f"v3/glossaries/{glossary_id}/dictionaries" + ), + headers={"Content-Type": "application/json"}, + body=body, + ) + + +def _parse_multilingual_glossary_dict_response( + response: HttpResponse, +) -> MultilingualGlossaryDictionaryInfo: + json_data = json_module.loads(response.content) + return MultilingualGlossaryDictionaryInfo.from_json(json_data) + + +def _build_get_multilingual_glossary_request( + server_url: str, glossary: Union[str, MultilingualGlossaryInfo] +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, f"v3/glossaries/{glossary_id}"), + ) + + +def _build_list_multilingual_glossaries_request( + server_url: str, +) -> HttpRequest: + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, "v3/glossaries"), + ) + + +def _parse_list_multilingual_glossaries_response( + response: HttpResponse, +) -> List[MultilingualGlossaryInfo]: + json_data = json_module.loads(response.content) + glossaries = ( + json_data.get("glossaries", []) if isinstance(json_data, dict) else [] + ) + return [MultilingualGlossaryInfo.from_json(g) for g in glossaries] + + +def _build_get_multilingual_glossary_entries_request( + server_url: str, + glossary: Union[str, MultilingualGlossaryInfo], + source_lang: str, + target_lang: str, +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + source_lang = Language.remove_regional_variant(source_lang) + target_lang = Language.remove_regional_variant(target_lang) + qs = urllib.parse.urlencode( + {"source_lang": source_lang, "target_lang": target_lang} + ) + url = urllib.parse.urljoin( + server_url, f"v3/glossaries/{glossary_id}/entries?{qs}" + ) + return HttpRequest(method="GET", url=url) + + +def _parse_multilingual_glossary_entries_response( + response: HttpResponse, +) -> MultilingualGlossaryDictionaryEntriesResponse: + json_data = json_module.loads(response.content) + return MultilingualGlossaryDictionaryEntriesResponse.from_json(json_data) + + +def _build_delete_multilingual_glossary_request( + server_url: str, glossary: Union[str, MultilingualGlossaryInfo] +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + return HttpRequest( + method="DELETE", + url=urllib.parse.urljoin(server_url, f"v3/glossaries/{glossary_id}"), + ) + + +def _build_delete_multilingual_glossary_dict_request( + server_url: str, + glossary: Union[str, MultilingualGlossaryInfo], + source_lang: Optional[str] = None, + target_lang: Optional[str] = None, + dictionary: Optional[MultilingualGlossaryDictionaryInfo] = None, +) -> HttpRequest: + glossary_id = _safe_id( + glossary.glossary_id + if isinstance(glossary, MultilingualGlossaryInfo) + else glossary + ) + if dictionary is not None: + source_lang = dictionary.source_lang + target_lang = dictionary.target_lang + if not source_lang or not target_lang: + raise ValueError( + "must provide dictionary or both source_lang and target_lang" + ) + source_lang = Language.remove_regional_variant(source_lang) + target_lang = Language.remove_regional_variant(target_lang) + qs = urllib.parse.urlencode( + {"source_lang": source_lang, "target_lang": target_lang} + ) + url = urllib.parse.urljoin( + server_url, + f"v3/glossaries/{glossary_id}/dictionaries?{qs}", + ) + return HttpRequest(method="DELETE", url=url) diff --git a/deepl/_methods/_languages.py b/deepl/_methods/_languages.py new file mode 100644 index 0000000..375f5e3 --- /dev/null +++ b/deepl/_methods/_languages.py @@ -0,0 +1,69 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import urllib.parse +from typing import List + +from ..api_data import GlossaryLanguagePair, Language +from .._http_types import HttpRequest, HttpResponse + + +def _build_get_source_languages_request(server_url: str) -> HttpRequest: + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, "v2/languages"), + ) + + +def _build_get_target_languages_request(server_url: str) -> HttpRequest: + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, "v2/languages?type=target"), + ) + + +def _parse_get_source_languages_response( + response: HttpResponse, +) -> List[Language]: + json_data = json_module.loads(response.content) + languages = json_data if isinstance(json_data, list) else [] + return [Language(lang["language"], lang["name"]) for lang in languages] + + +def _parse_get_target_languages_response( + response: HttpResponse, +) -> List[Language]: + json_data = json_module.loads(response.content) + languages = json_data if isinstance(json_data, list) else [] + return [ + Language( + lang["language"], + lang["name"], + lang.get("supports_formality", None), + ) + for lang in languages + ] + + +def _build_get_glossary_languages_request(server_url: str) -> HttpRequest: + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, "v2/glossary-language-pairs"), + ) + + +def _parse_get_glossary_languages_response( + response: HttpResponse, +) -> List[GlossaryLanguagePair]: + json_data = json_module.loads(response.content) + supported = ( + json_data.get("supported_languages", []) + if isinstance(json_data, dict) + else [] + ) + return [ + GlossaryLanguagePair(p["source_lang"], p["target_lang"]) + for p in supported + ] diff --git a/deepl/_methods/_style_rules.py b/deepl/_methods/_style_rules.py new file mode 100644 index 0000000..38e43dc --- /dev/null +++ b/deepl/_methods/_style_rules.py @@ -0,0 +1,210 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import urllib.parse +from typing import Dict, List, Optional + +from ..api_data import CustomInstruction, StyleRuleInfo +from .._http_types import HttpRequest, HttpResponse + + +def _safe_id(style_rule_id: str) -> str: + """URL-encode a style rule or instruction ID for safe interpolation.""" + return urllib.parse.quote(style_rule_id, safe="") + + +def _build_get_style_rules_request( + server_url: str, + page: Optional[int] = None, + page_size: Optional[int] = None, + detailed: Optional[bool] = None, +) -> HttpRequest: + params = {} + if page is not None: + params["page"] = str(page) + if page_size is not None: + params["page_size"] = str(page_size) + if detailed is not None: + params["detailed"] = str(detailed).lower() + + endpoint = "v3/style_rules" + if params: + endpoint += "?" + urllib.parse.urlencode(params) + + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, endpoint), + ) + + +def _parse_get_style_rules_response( + response: HttpResponse, +) -> List[StyleRuleInfo]: + json_data = json_module.loads(response.content) + style_rules = ( + json_data.get("style_rules", []) if isinstance(json_data, dict) else [] + ) + return [StyleRuleInfo.from_json(rule) for rule in style_rules] + + +def _build_create_style_rule_request( + server_url: str, + name: str, + language: str, + configured_rules: Optional[dict] = None, + custom_instructions: Optional[List[dict]] = None, +) -> HttpRequest: + request_data: Dict = {"name": name, "language": language} + if configured_rules is not None: + request_data["configured_rules"] = configured_rules + if custom_instructions is not None: + request_data["custom_instructions"] = custom_instructions + return HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, "v3/style_rules"), + body=json_module.dumps(request_data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + + +def _parse_style_rule_response(response: HttpResponse) -> StyleRuleInfo: + json_data = json_module.loads(response.content) + return StyleRuleInfo.from_json(json_data) + + +def _build_get_style_rule_request( + server_url: str, + style_rule_id: str, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, f"v3/style_rules/{sid}"), + ) + + +def _build_update_style_rule_name_request( + server_url: str, + style_rule_id: str, + name: str, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + body = json_module.dumps({"name": name}).encode("utf-8") + return HttpRequest( + method="PATCH", + url=urllib.parse.urljoin(server_url, f"v3/style_rules/{sid}"), + body=body, + headers={"Content-Type": "application/json"}, + ) + + +def _build_delete_style_rule_request( + server_url: str, + style_rule_id: str, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + return HttpRequest( + method="DELETE", + url=urllib.parse.urljoin(server_url, f"v3/style_rules/{sid}"), + ) + + +def _build_update_style_rule_configured_rules_request( + server_url: str, + style_rule_id: str, + configured_rules: dict, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + body = json_module.dumps(configured_rules).encode("utf-8") + return HttpRequest( + method="PUT", + url=urllib.parse.urljoin( + server_url, + f"v3/style_rules/{sid}/configured_rules", + ), + body=body, + headers={"Content-Type": "application/json"}, + ) + + +def _build_create_style_rule_custom_instruction_request( + server_url: str, + style_rule_id: str, + label: str, + prompt: str, + source_language: Optional[str] = None, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + request_data: Dict = {"label": label, "prompt": prompt} + if source_language is not None: + request_data["source_language"] = source_language + body = json_module.dumps(request_data).encode("utf-8") + return HttpRequest( + method="POST", + url=urllib.parse.urljoin( + server_url, + f"v3/style_rules/{sid}/custom_instructions", + ), + body=body, + headers={"Content-Type": "application/json"}, + ) + + +def _parse_custom_instruction_response( + response: HttpResponse, +) -> CustomInstruction: + json_data = json_module.loads(response.content) + return CustomInstruction.from_json(json_data) + + +def _build_get_style_rule_custom_instruction_request( + server_url: str, + style_rule_id: str, + instruction_id: str, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + iid = _safe_id(instruction_id) + path = f"v3/style_rules/{sid}/custom_instructions/{iid}" + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, path), + ) + + +def _build_update_style_rule_custom_instruction_request( + server_url: str, + style_rule_id: str, + instruction_id: str, + label: str, + prompt: str, + source_language: Optional[str] = None, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + iid = _safe_id(instruction_id) + request_data: Dict = {"label": label, "prompt": prompt} + if source_language is not None: + request_data["source_language"] = source_language + body = json_module.dumps(request_data).encode("utf-8") + path = f"v3/style_rules/{sid}/custom_instructions/{iid}" + return HttpRequest( + method="PUT", + url=urllib.parse.urljoin(server_url, path), + body=body, + headers={"Content-Type": "application/json"}, + ) + + +def _build_delete_style_rule_custom_instruction_request( + server_url: str, + style_rule_id: str, + instruction_id: str, +) -> HttpRequest: + sid = _safe_id(style_rule_id) + iid = _safe_id(instruction_id) + path = f"v3/style_rules/{sid}/custom_instructions/{iid}" + return HttpRequest( + method="DELETE", + url=urllib.parse.urljoin(server_url, path), + ) diff --git a/deepl/_methods/_translate.py b/deepl/_methods/_translate.py new file mode 100644 index 0000000..8342ebc --- /dev/null +++ b/deepl/_methods/_translate.py @@ -0,0 +1,199 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import urllib.parse +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union + +from ..exceptions import DeepLException +from ..api_data import ( + Formality, + GlossaryInfo, + Language, + ModelType, + MultilingualGlossaryInfo, + SplitSentences, + StyleRuleInfo, + TextResult, + TranslationMemoryInfo, +) +from .._client_base import _check_language_and_formality +from .._http_types import HttpRequest, HttpResponse + + +def _join_tags(tag_argument: Union[str, Iterable[str]]) -> List[str]: + if isinstance(tag_argument, str): + tag_argument = [tag_argument] + return [ + tag for arg_string in tag_argument for tag in arg_string.split(",") + ] + + +def _build_translate_text_request( + server_url: str, + text: Union[str, Iterable[str]], + *, + target_lang: Union[str, Language], + source_lang: Union[str, Language, None] = None, + context: Optional[str] = None, + split_sentences: Union[str, SplitSentences, None] = None, + preserve_formatting: Optional[bool] = None, + formality: Union[str, Formality, None] = None, + glossary: Union[str, GlossaryInfo, MultilingualGlossaryInfo, None] = None, + tag_handling: Optional[str] = None, + tag_handling_version: Optional[str] = None, + outline_detection: Optional[bool] = None, + non_splitting_tags: Union[str, List[str], None] = None, + splitting_tags: Union[str, List[str], None] = None, + ignore_tags: Union[str, List[str], None] = None, + model_type: Union[str, ModelType, None] = None, + style_rule: Union[str, StyleRuleInfo, None] = None, + translation_memory: Union[str, TranslationMemoryInfo, None] = None, + translation_memory_threshold: Optional[int] = None, + custom_instructions: Optional[List[str]] = None, + extra_body_parameters: Optional[dict] = None, +) -> Tuple[HttpRequest, bool]: + """Build a POST /v2/translate request. + + Returns (request, multi_input) where multi_input is True if ``text`` was + an iterable (so the caller knows whether to unwrap the single result). + """ + if isinstance(text, str): + if len(text) == 0: + raise ValueError("text must not be empty") + text_list = [text] + multi_input = False + elif hasattr(text, "__iter__"): + multi_input = True + text_list = list(text) + if len(text_list) == 0: + raise ValueError("text must not be empty") + else: + raise TypeError( + "text parameter must be a string or an iterable of strings" + ) + + request_data: Dict[str, Any] = _check_language_and_formality( + source_lang, target_lang, formality, glossary, style_rule + ) + request_data["text"] = text_list + request_data["show_billed_characters"] = True + if isinstance(translation_memory, TranslationMemoryInfo): + request_data["translation_memory_id"] = ( + translation_memory.translation_memory_id + ) + elif translation_memory is not None: + request_data["translation_memory_id"] = translation_memory + if translation_memory_threshold is not None: + if translation_memory is None: + raise ValueError( + "translation_memory_threshold requires translation_memory" + ) + if not (0 <= translation_memory_threshold <= 100): + raise ValueError( + "translation_memory_threshold must be between 0 and 100" + ) + request_data["translation_memory_threshold"] = ( + translation_memory_threshold + ) + + if context is not None: + request_data["context"] = context + if split_sentences is not None: + request_data["split_sentences"] = str(split_sentences) + if preserve_formatting is not None: + request_data["preserve_formatting"] = bool(preserve_formatting) + if tag_handling is not None: + request_data["tag_handling"] = tag_handling + if tag_handling_version is not None: + request_data["tag_handling_version"] = tag_handling_version + if outline_detection is not None: + request_data["outline_detection"] = bool(outline_detection) + if model_type is not None: + request_data["model_type"] = str(model_type) + + if non_splitting_tags is not None: + request_data["non_splitting_tags"] = _join_tags(non_splitting_tags) + if splitting_tags is not None: + request_data["splitting_tags"] = _join_tags(splitting_tags) + if ignore_tags is not None: + request_data["ignore_tags"] = _join_tags(ignore_tags) + if custom_instructions is not None: + request_data["custom_instructions"] = custom_instructions + if extra_body_parameters: + request_data.update(extra_body_parameters) + + body = json_module.dumps(request_data).encode("utf-8") + return ( + HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, "v2/translate"), + headers={"Content-Type": "application/json"}, + body=body, + ), + multi_input, + ) + + +def _parse_translate_text_response( + response: HttpResponse, + multi_input: bool, +) -> Union[TextResult, List[TextResult]]: + json_data = json_module.loads(response.content) + translations = ( + json_data.get("translations", []) + if isinstance(json_data, dict) + else [] + ) + output = [] + for translation in translations: + text = translation.get("text", "") if translation else "" + detected_source_language = ( + translation.get("detected_source_language", "") + if translation + else "" + ) + billed_characters = int(translation.get("billed_characters", 0)) + model_type_used = translation.get("model_type_used") + output.append( + TextResult( + text, + detected_source_language, + billed_characters, + model_type_used, + ) + ) + if not output and not multi_input: + raise DeepLException("Unexpected empty translations in API response") + return output if multi_input else output[0] + + +def _build_list_translation_memories_request( + server_url: str, + *, + page: Optional[int] = None, + page_size: Optional[int] = None, +) -> HttpRequest: + """Build a GET /v3/translation_memories request.""" + params: Dict[str, str] = {} + if page is not None: + params["page"] = str(page) + if page_size is not None: + params["page_size"] = str(page_size) + url = urllib.parse.urljoin(server_url, "v3/translation_memories") + if params: + url += "?" + urllib.parse.urlencode(params) + return HttpRequest(method="GET", url=url) + + +def _parse_list_translation_memories_response( + response: HttpResponse, +) -> List[TranslationMemoryInfo]: + json_data = json_module.loads(response.content) + memories = ( + json_data.get("translation_memories", []) + if isinstance(json_data, dict) + else [] + ) + return [TranslationMemoryInfo.from_json(m) for m in memories] diff --git a/deepl/_methods/_usage.py b/deepl/_methods/_usage.py new file mode 100644 index 0000000..94cecde --- /dev/null +++ b/deepl/_methods/_usage.py @@ -0,0 +1,23 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import urllib.parse + +from ..api_data import Usage +from .._http_types import HttpRequest, HttpResponse + + +def _build_get_usage_request(server_url: str) -> HttpRequest: + return HttpRequest( + method="GET", + url=urllib.parse.urljoin(server_url, "v2/usage"), + ) + + +def _parse_get_usage_response(response: HttpResponse) -> Usage: + json_data = json_module.loads(response.content) + if not isinstance(json_data, dict): + json_data = {} + return Usage(json_data) diff --git a/deepl/_methods/_write.py b/deepl/_methods/_write.py new file mode 100644 index 0000000..c5f47a7 --- /dev/null +++ b/deepl/_methods/_write.py @@ -0,0 +1,88 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import json as json_module +import urllib.parse +from typing import Iterable, List, Optional, Tuple, Union + +from ..exceptions import DeepLException +from ..api_data import Language, WriteResult +from .._http_types import HttpRequest, HttpResponse + + +def _build_rephrase_text_request( + server_url: str, + text: Union[str, Iterable[str]], + *, + target_lang: Union[None, str, Language] = None, + style: Optional[str] = None, + tone: Optional[str] = None, +) -> Tuple[HttpRequest, bool]: + """Build a POST /v2/write/rephrase request. + + Returns (request, multi_input). + """ + if isinstance(text, str): + if len(text) == 0: + raise ValueError("text must not be empty") + text_list = [text] + multi_input = False + elif hasattr(text, "__iter__"): + multi_input = True + text_list = list(text) + else: + raise TypeError( + "text parameter must be a string or an iterable of strings" + ) + + target_lang_str: Optional[str] = ( + str(target_lang) if target_lang is not None else None + ) + request_data: dict = {"text": text_list} + if target_lang_str: + request_data["target_lang"] = target_lang_str + if style: + request_data["writing_style"] = style + if tone: + request_data["tone"] = tone + + body = json_module.dumps(request_data).encode("utf-8") + return ( + HttpRequest( + method="POST", + url=urllib.parse.urljoin(server_url, "v2/write/rephrase"), + headers={"Content-Type": "application/json"}, + body=body, + ), + multi_input, + ) + + +def _parse_rephrase_text_response( + response: HttpResponse, + multi_input: bool, +) -> Union[WriteResult, List[WriteResult]]: + json_data = json_module.loads(response.content) + improvements = ( + json_data.get("improvements", []) + if isinstance(json_data, dict) + else [] + ) + output = [] + for improvement in improvements: + text = improvement.get("text", "") if improvement else "" + detected_source_language = ( + improvement.get("detected_source_language", "") + if improvement + else "" + ) + target_language = ( + improvement.get("target_language", "") if improvement else "" + ) + output.append( + WriteResult(text, detected_source_language, target_language) + ) + if not output and not multi_input: + raise DeepLException("Unexpected empty improvements in API response") + return output if multi_input else output[0] diff --git a/deepl/aiohttp_client.py b/deepl/aiohttp_client.py new file mode 100644 index 0000000..45028e8 --- /dev/null +++ b/deepl/aiohttp_client.py @@ -0,0 +1,280 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import asyncio +from typing import Any, AsyncIterator, Dict, List, Optional, Union + +from .exceptions import ConnectionException +from ._http_types import HttpRequest, HttpResponse + +try: + import aiohttp + import aiohttp.client_exceptions +except ImportError as _import_error: + aiohttp = None # type: ignore[assignment] + _aiohttp_import_error: Optional[ImportError] = _import_error +else: + _aiohttp_import_error = None + + +class _AioHttpStreamingResponse: + """Wraps an aiohttp.ClientResponse as an AsyncStreamingHttpResponse. + + Usable as an async context manager so callers don't have to remember + ``await streaming.close()``: + + async with await client.translate_document_download(handle) as resp: + async for chunk in resp.aiter_content(): + ... + """ + + def __init__(self, response: "aiohttp.ClientResponse") -> None: + self._response = response + self.status_code: int = response.status + self.headers: Dict[str, str] = dict(response.headers) + + def aiter_content(self, chunk_size: int = 65536) -> AsyncIterator[bytes]: + return self._response.content.iter_chunked(chunk_size) + + async def close(self) -> None: + await self._response.release() + + async def __aenter__(self) -> "_AioHttpStreamingResponse": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + +class AioHttpClient: + """Async HTTP client backed by :mod:`aiohttp`. + + Creates a single :class:`aiohttp.ClientSession` lazily on first use so + the session is always bound to the running event loop. + + Makes exactly one attempt per call — retry logic lives in + ``DeepLClientAsync._send_with_backoff()``. + + All library-specific network errors are translated to + :class:`~deepl.exceptions.ConnectionException` before raising. + + :param proxy: Proxy URL string. Dict form not supported by aiohttp. + :param verify_ssl: SSL verification config (see :data:`~deepl.SslConfig`). + """ + + def __init__( + self, + proxy: Union[Dict, str, None] = None, + verify_ssl: Union[bool, str, None] = None, + ) -> None: + if aiohttp is None: + raise ImportError( + "aiohttp is required for async support. " + "Install it with: pip install deepl[async]" + ) from _aiohttp_import_error + + if isinstance(proxy, dict): + proxy = proxy.get("https") or proxy.get("http") + self._proxy: Optional[str] = proxy + self._verify_ssl = verify_ssl + self._session: Optional["aiohttp.ClientSession"] = None + self._session_loop: Optional[asyncio.AbstractEventLoop] = None + # Stale sessions waiting to be closed. Populated when the event + # loop changes between calls (e.g. tests using asyncio.run); drained + # best-effort by close(). + self._pending_close_sessions: List["aiohttp.ClientSession"] = [] + + def _build_connector_kwargs(self) -> Dict: + kwargs: Dict = {} + if self._verify_ssl is False: + kwargs["ssl"] = False + elif isinstance(self._verify_ssl, str): + import ssl + + kwargs["ssl"] = ssl.create_default_context(cafile=self._verify_ssl) + return kwargs + + def _get_session(self) -> "aiohttp.ClientSession": + """Return or create the session, re-creating if the event loop changed. + + aiohttp ≥3.9 binds a ClientSession to the loop that was running when + it was constructed. Re-using a session across asyncio.run() calls (e.g. + in test suites) raises RuntimeError. We detect this by comparing the + session's internal loop against the currently running loop and + transparently replace it. + """ + loop = asyncio.get_running_loop() + stale = ( + self._session is None + or self._session.closed + or self._session_loop is not loop + ) + if stale: + if self._session is not None and not self._session.closed: + self._pending_close_sessions.append(self._session) + self._session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector( + **self._build_connector_kwargs() + ) + ) + self._session_loop = loop + assert self._session is not None + return self._session + + async def send(self, request: HttpRequest) -> HttpResponse: + """Make one async HTTP request; return a buffered response. + + :raises ConnectionException: On any network error. + """ + session = self._get_session() + # Mirror the synchronous client's semantics: `requests` resets + # its timeout per socket operation, so a slow but steady transfer + # (e.g. large document download on a poor link) does not abort + # while bytes are still arriving. Bound connect and any gap + # between reads; leave overall wall clock unbounded. + timeout = aiohttp.ClientTimeout( + sock_connect=request.timeout, sock_read=request.timeout + ) + try: + if request.multipart is not None: + mp = request.multipart + form = aiohttp.FormData() + for key, value in mp.fields.items(): + form.add_field(key, value) + form.add_field( + "file", + mp.file_factory(), + filename=mp.file_name, + content_type=mp.file_content_type, + ) + resp = await session.request( + request.method, + request.url, + data=form, + headers=request.headers, + timeout=timeout, + proxy=self._proxy, + ) + elif request.body is not None: + resp = await session.request( + request.method, + request.url, + data=request.body, + headers=request.headers, + timeout=timeout, + proxy=self._proxy, + ) + else: + resp = await session.request( + request.method, + request.url, + headers=request.headers, + timeout=timeout, + proxy=self._proxy, + ) + content = await resp.read() + await resp.release() + return HttpResponse( + status_code=resp.status, + headers=dict(resp.headers), + content=content, + ) + except asyncio.TimeoutError as e: + raise ConnectionException( + f"Request timed out: {e}", should_retry=True + ) from e + except aiohttp.client_exceptions.ClientConnectionError as e: + raise ConnectionException( + f"Connection failed: {e}", should_retry=True + ) from e + except aiohttp.ClientError as e: + raise ConnectionException( + f"Request failed: {e}", should_retry=False + ) from e + except Exception as e: + raise ConnectionException( + f"Unexpected request failure: {e}", should_retry=False + ) from e + + async def send_streaming( + self, request: HttpRequest + ) -> _AioHttpStreamingResponse: + """Make one async HTTP request; return a streaming response. + + The caller is responsible for consuming the response body via + ``aiter_content()``. + + :raises ConnectionException: On any network error before response + headers are received. + """ + session = self._get_session() + # Mirror the synchronous client's semantics: `requests` resets + # its timeout per socket operation, so a slow but steady transfer + # (e.g. large document download on a poor link) does not abort + # while bytes are still arriving. Bound connect and any gap + # between reads; leave overall wall clock unbounded. + timeout = aiohttp.ClientTimeout( + sock_connect=request.timeout, sock_read=request.timeout + ) + try: + if request.body is not None: + resp = await session.request( + request.method, + request.url, + data=request.body, + headers=request.headers, + timeout=timeout, + proxy=self._proxy, + ) + else: + resp = await session.request( + request.method, + request.url, + headers=request.headers, + timeout=timeout, + proxy=self._proxy, + ) + return _AioHttpStreamingResponse(resp) + except asyncio.TimeoutError as e: + raise ConnectionException( + f"Request timed out: {e}", should_retry=True + ) from e + except aiohttp.client_exceptions.ClientConnectionError as e: + raise ConnectionException( + f"Connection failed: {e}", should_retry=True + ) from e + except aiohttp.ClientError as e: + raise ConnectionException( + f"Request failed: {e}", should_retry=False + ) from e + except Exception as e: + raise ConnectionException( + f"Unexpected request failure: {e}", should_retry=False + ) from e + + @property + def http_library_info(self) -> str: + if aiohttp is None: + return "aiohttp/unknown" + return f"aiohttp/{aiohttp.__version__}" + + async def close(self) -> None: + """Close the aiohttp session and any sessions abandoned on loop + changes. + + Sessions are bound to the loop they were created on; closing one + from a different loop will fail. We swallow such errors so close() + is safe to call from any loop. + """ + for old in self._pending_close_sessions: + if old.closed: + continue + try: + await old.close() + except Exception: + pass + self._pending_close_sessions.clear() + if self._session is not None and not self._session.closed: + await self._session.close() + self._session = None diff --git a/deepl/deepl_client.py b/deepl/deepl_client.py index 8f7d923..f1bcfb1 100644 --- a/deepl/deepl_client.py +++ b/deepl/deepl_client.py @@ -2,23 +2,15 @@ # Use of this source code is governed by an MIT # license that can be found in the LICENSE file. -from deepl.api_data import ( - CustomInstruction, - MultilingualGlossaryDictionaryEntries, - MultilingualGlossaryDictionaryEntriesResponse, - MultilingualGlossaryDictionaryInfo, - MultilingualGlossaryInfo, - Language, - WriteResult, - StyleRuleInfo, - TranslationMemoryInfo, -) -from deepl.translator import Translator -from deepl import util -import urllib.parse +import json as json_module +import os +import pathlib +import time +import warnings from typing import ( Any, BinaryIO, + Callable, Dict, Iterable, List, @@ -27,8 +19,126 @@ Union, ) +from .api_data import ( + CustomInstruction, + DocumentHandle, + DocumentStatus, + Formality, + GlossaryInfo, + GlossaryLanguagePair, + Language, + ModelType, + MultilingualGlossaryDictionaryEntries, + MultilingualGlossaryDictionaryEntriesResponse, + MultilingualGlossaryDictionaryInfo, + MultilingualGlossaryInfo, + SplitSentences, + StyleRuleInfo, + TextResult, + TranslationMemoryInfo, + Usage, + WriteResult, +) +from ._backoff_timer import BackoffTimer +from ._client_base import _ClientBase +from ._http_types import ( + HttpRequest, + HttpResponse, + SslConfig, + StreamingHttpResponse, +) +from ._methods import ( + _build_create_glossary_request, + _build_create_multilingual_glossary_request, + _build_delete_glossary_request, + _build_delete_multilingual_glossary_dict_request, + _build_delete_multilingual_glossary_request, + _build_document_download_request, + _build_document_status_request, + _build_document_upload_request, + _build_get_glossary_entries_request, + _build_get_glossary_languages_request, + _build_get_glossary_request, + _build_get_multilingual_glossary_entries_request, + _build_get_multilingual_glossary_request, + _build_get_source_languages_request, + _build_create_style_rule_custom_instruction_request, + _build_create_style_rule_request, + _build_delete_style_rule_custom_instruction_request, + _build_delete_style_rule_request, + _build_get_style_rule_custom_instruction_request, + _build_get_style_rule_request, + _build_get_style_rules_request, + _build_get_target_languages_request, + _build_update_style_rule_configured_rules_request, + _build_update_style_rule_custom_instruction_request, + _build_update_style_rule_name_request, + _build_get_usage_request, + _build_list_glossaries_request, + _build_list_multilingual_glossaries_request, + _build_list_translation_memories_request, + _build_rephrase_text_request, + _build_replace_multilingual_glossary_dict_request, + _build_translate_text_request, + _build_update_multilingual_glossary_dict_request, + _build_update_multilingual_glossary_name_request, + _parse_create_glossary_response, + _parse_document_status_response, + _parse_document_upload_response, + _parse_get_glossary_entries_response, + _parse_get_glossary_languages_response, + _parse_get_glossary_response, + _parse_get_source_languages_response, + _parse_custom_instruction_response, + _parse_get_style_rules_response, + _parse_get_target_languages_response, + _parse_style_rule_response, + _parse_get_usage_response, + _parse_list_glossaries_response, + _parse_list_multilingual_glossaries_response, + _parse_multilingual_glossary_dict_response, + _parse_multilingual_glossary_entries_response, + _parse_multilingual_glossary_response, + _parse_list_translation_memories_response, + _parse_rephrase_text_response, + _parse_translate_text_response, +) +from .exceptions import ( + ConnectionException, + DeepLException, + DocumentTranslationException, +) +from .ihttp_client import HttpClientProtocol +from .requests_client import RequestsClient +from .retry_config import RetryConfig +from . import util + +_DEFAULT_RETRY_CONFIG = RetryConfig() + + +class DeepLClient(_ClientBase): + """Client for the DeepL API. + + :param auth_key: Authentication key as found in your DeepL API account. + :param server_url: (Optional) Base URL of DeepL API, can be overridden + for testing purposes. + :param proxy: (Optional) Proxy URL string or dict with ``"http"``/ + ``"https"`` keys. Forwarded to the default :class:`RequestsClient`. + Raises :exc:`ValueError` if ``http_client`` is also supplied. + :param send_platform_info: (Optional) If True (default), include OS and + Python version in User-Agent header. + :param verify_ssl: (Optional) SSL certificate verification. Forwarded to + the default :class:`RequestsClient`. Raises :exc:`ValueError` if + ``http_client`` is also supplied. See :data:`SslConfig`. + :param http_client: (Optional) Custom HTTP client implementing + :class:`~deepl.HttpClientProtocol`. When supplied, ``proxy`` and + ``verify_ssl`` must not be set. + :param retry_config: (Optional) Backoff/retry settings. Applies regardless + of which ``http_client`` is used. + :param skip_language_check: Deprecated, no-op. Will be removed in a + future version. + """ -class DeepLClient(Translator): def __init__( self, auth_key: str, @@ -36,125 +146,728 @@ def __init__( server_url: Optional[str] = None, proxy: Union[Dict, str, None] = None, send_platform_info: bool = True, - verify_ssl: Union[bool, str, None] = None, + verify_ssl: SslConfig = None, + http_client: Optional[HttpClientProtocol] = None, + retry_config: RetryConfig = _DEFAULT_RETRY_CONFIG, skip_language_check: bool = False, - ): - super().__init__( - auth_key, - server_url=server_url, - proxy=proxy, - send_platform_info=send_platform_info, - verify_ssl=verify_ssl, - skip_language_check=skip_language_check, + _sleep_fn: Optional[Callable[[float], None]] = None, + ) -> None: + super().__init__(auth_key, server_url, send_platform_info) + + from . import http_client as _hc + + if _hc.max_network_retries is not None or ( + _hc.min_connection_timeout is not None + ): + retry_config = RetryConfig( + max_retries=( + _hc.max_network_retries + if _hc.max_network_retries is not None + else retry_config.max_retries + ), + min_connection_timeout=( + _hc.min_connection_timeout + if _hc.min_connection_timeout is not None + else retry_config.min_connection_timeout + ), + backoff_initial=retry_config.backoff_initial, + backoff_max=retry_config.backoff_max, + backoff_multiplier=retry_config.backoff_multiplier, + backoff_jitter=retry_config.backoff_jitter, + ) + + if http_client is not None and ( + proxy is not None or verify_ssl is not None + ): + raise ValueError( + "proxy and verify_ssl must not be set when " + "http_client is supplied; configure the " + "http_client directly instead." + ) + + if skip_language_check: + warnings.warn( + "skip_language_check is deprecated and has no effect.", + DeprecationWarning, + stacklevel=2, + ) + + resolved_client: HttpClientProtocol + if http_client is None: + resolved_client = RequestsClient( + proxy=proxy, + verify_ssl=verify_ssl, + ) + else: + resolved_client = http_client + + self._http_client = resolved_client + self._http_library_info = resolved_client.http_library_info + if _hc.user_agent is not None: + self.set_user_agent(_hc.user_agent) + self._retry_config = retry_config + self._sleep_fn: Callable[[float], None] = ( + _sleep_fn if _sleep_fn is not None else time.sleep ) + self.headers: Dict[str, str] = {} - def rephrase_text( + # ------------------------------------------------------------------ + # Context manager / lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close the underlying HTTP client and release resources.""" + if hasattr(self, "_http_client"): + self._http_client.close() + + def __enter__(self) -> "DeepLClient": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + def __del__(self) -> None: + self.close() + + # ------------------------------------------------------------------ + # Internal: retry loop + # ------------------------------------------------------------------ + + def _send_with_backoff(self, request: HttpRequest) -> HttpResponse: + """Send a request with exponential-backoff retry. + + Adds auth headers, then retries on ConnectionException (with + should_retry=True), 429, and 5xx up to retry_config.max_retries times. + """ + max_retries = self._retry_config.max_retries + min_timeout = self._retry_config.min_connection_timeout + + auth_headers = self._make_auth_headers() + merged_headers = {**auth_headers, **self.headers, **request.headers} + base_request = HttpRequest( + method=request.method, + url=request.url, + headers=merged_headers, + body=request.body, + multipart=request.multipart, + ) + + util.log_info( + "Request to DeepL API", + method=base_request.method, + url=base_request.url, + ) + if base_request.body: + try: + body_json = json_module.loads(base_request.body) + except Exception: + body_json = base_request.body.decode("utf-8", errors="replace") + util.log_debug("Request details", data={}, json=body_json) + elif base_request.multipart: + util.log_debug( + "Request details", + data=base_request.multipart.fields, + json=None, + ) + else: + util.log_debug("Request details", data={}, json=None) + + timer = BackoffTimer(self._retry_config) + while True: + request = HttpRequest( + method=base_request.method, + url=base_request.url, + headers=base_request.headers, + body=base_request.body, + multipart=base_request.multipart, + timeout=timer.get_timeout(min_timeout), + ) + response: Optional[HttpResponse] = None + exception: Optional[ConnectionException] = None + try: + response = self._http_client.send(request) + except ConnectionException as e: + exception = e + + if exception is not None: + if ( + exception.should_retry + and timer.get_num_retries() < max_retries + ): + util.log_info( + f"Encountered a retryable-exception: {exception}" + ) + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + raise exception + + assert response is not None + util.log_info( + "DeepL API response", + url=request.url, + status_code=response.status_code, + ) + status = response.status_code + # Retry on 429 and any 5xx uniformly. The retry decision is made + # here before _raise_for_status, so it is independent of the + # should_retry flag on the eventual exception. All 5xx responses + # are considered transient at this layer. + if ( + status == 429 or status >= 500 + ) and timer.get_num_retries() < max_retries: + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} for " + f"HTTP {status} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + + return response + + def _send_streaming_with_backoff( + self, request: HttpRequest + ) -> StreamingHttpResponse: + """Like _send_with_backoff but for streaming (document download).""" + max_retries = self._retry_config.max_retries + min_timeout = self._retry_config.min_connection_timeout + + auth_headers = self._make_auth_headers() + merged_headers = {**auth_headers, **self.headers, **request.headers} + base_request = HttpRequest( + method=request.method, + url=request.url, + headers=merged_headers, + body=request.body, + multipart=request.multipart, + ) + + util.log_info( + "Request to DeepL API", + method=base_request.method, + url=base_request.url, + ) + + timer = BackoffTimer(self._retry_config) + while True: + request = HttpRequest( + method=base_request.method, + url=base_request.url, + headers=base_request.headers, + body=base_request.body, + multipart=base_request.multipart, + timeout=timer.get_timeout(min_timeout), + ) + exception: Optional[ConnectionException] = None + try: + streaming = self._http_client.send_streaming(request) + except ConnectionException as e: + exception = e + streaming = None # type: ignore[assignment] + + if exception is not None: + if ( + exception.should_retry + and timer.get_num_retries() < max_retries + ): + util.log_info( + f"Encountered a retryable-exception: {exception}" + ) + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + raise exception + + assert streaming is not None + util.log_info( + "DeepL API response", + url=request.url, + status_code=streaming.status_code, + ) + status = streaming.status_code + if ( + status == 429 or status >= 500 + ) and timer.get_num_retries() < max_retries: + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} for " + f"HTTP {status} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + # Consume and discard the error body before retrying. + for _ in streaming.iter_content(65536): + pass + self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + + return streaming + + # ------------------------------------------------------------------ + # Public API: text translation + # ------------------------------------------------------------------ + + def translate_text( self, text: Union[str, Iterable[str]], *, - target_lang: Union[None, str, Language] = None, - style: Optional[str] = None, - tone: Optional[str] = None, - ) -> Union[WriteResult, List[WriteResult]]: - """Improve the text(s) and optionally convert them to the variant of - the `target_lang` (requires source lang to match target_lang, excluding - variants). - - :param text: Text to improve. - :type text: UTF-8 :class:`str`; string sequence (list, tuple, iterator, - generator) - :param target_lang: language code the final text should be in, for - example "DE", "EN-US", "FR". - :param style: Writing style to be used for the improvement. Either - style OR tone can be used. - :param tone: Tone to be used for the improvement. Either style OR tone - can be used. - :return: List of WriteResult objects containing results, unless input - text was one string, then a single WriteResult object is returned. + source_lang: Union[str, Language, None] = None, + target_lang: Union[str, Language], + context: Optional[str] = None, + split_sentences: Union[str, SplitSentences, None] = None, + preserve_formatting: Optional[bool] = None, + formality: Union[str, Formality, None] = None, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + tag_handling: Optional[str] = None, + tag_handling_version: Optional[str] = None, + outline_detection: Optional[bool] = None, + non_splitting_tags: Union[str, List[str], None] = None, + splitting_tags: Union[str, List[str], None] = None, + ignore_tags: Union[str, List[str], None] = None, + model_type: Union[str, ModelType, None] = None, + style_rule: Union[str, StyleRuleInfo, None] = None, + translation_memory: Union[str, TranslationMemoryInfo, None] = None, + translation_memory_threshold: Optional[int] = None, + custom_instructions: Optional[List[str]] = None, + extra_body_parameters: Optional[dict] = None, + ) -> Union[TextResult, List[TextResult]]: + """Translate text(s) into the target language. + + :param text: Text to translate. + :type text: UTF-8 :class:`str`; string sequence (list, tuple, + iterator, generator) + :param source_lang: (Optional) Language code of input text, for + example "DE", "EN", "FR". If omitted, DeepL will auto-detect. + :param target_lang: Language code to translate into, e.g. "DE", + "EN-US", "FR". + :param context: (Optional) Additional context text (not translated). + :param split_sentences: (Optional) Controls sentence splitting. + :param preserve_formatting: (Optional) Preserve formatting. + :param formality: (Optional) Desired formality level. + :param glossary: (Optional) Glossary or glossary ID. + :param tag_handling: (Optional) "xml" or "html". + :param tag_handling_version: (Optional) "v1" or "v2". + :param outline_detection: (Optional) Set False to disable auto + tag detection. + :param non_splitting_tags: (Optional) XML tags that should not + split a sentence. + :param splitting_tags: (Optional) XML tags that should split a + sentence. + :param ignore_tags: (Optional) XML tags whose content should not + be translated. + :param model_type: (Optional) Translation model quality level. + :param style_rule: (Optional) Style rule or style rule ID. + :param translation_memory: (Optional) Translation memory or ID. + :param translation_memory_threshold: (Optional) Minimum match + percentage for fuzzy matches (0-100). + :param custom_instructions: (Optional) List of custom instructions. + :param extra_body_parameters: (Optional) Additional JSON body fields. + :return: List of TextResult objects, or a single TextResult if input + was a single string. """ + request, multi_input = _build_translate_text_request( + self._server_url, + text, + target_lang=target_lang, + source_lang=source_lang, + context=context, + split_sentences=split_sentences, + preserve_formatting=preserve_formatting, + formality=formality, + glossary=glossary, + tag_handling=tag_handling, + tag_handling_version=tag_handling_version, + outline_detection=outline_detection, + non_splitting_tags=non_splitting_tags, + splitting_tags=splitting_tags, + ignore_tags=ignore_tags, + model_type=model_type, + style_rule=style_rule, + translation_memory=translation_memory, + translation_memory_threshold=translation_memory_threshold, + custom_instructions=custom_instructions, + extra_body_parameters=extra_body_parameters, + ) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_translate_text_response(response, multi_input) - if isinstance(text, str): - if len(text) == 0: - raise ValueError("text must not be empty") - text = [text] - multi_input = False - elif hasattr(text, "__iter__"): - multi_input = True - text = list(text) - else: - raise TypeError( - "text parameter must be a string or an iterable of strings" + def translate_text_with_glossary( + self, + text: Union[str, Iterable[str]], + glossary: GlossaryInfo, + target_lang: Union[str, Language, None] = None, + **kwargs: Any, + ) -> Union[TextResult, List[TextResult]]: + """Translate text using given glossary. + + Source and target languages are assumed to match the glossary. + Note: if glossary target language is EN, translates into EN-GB. + Specify target_lang="EN-US" to translate into American English. + + :param text: Text to translate. + :param glossary: GlossaryInfo to use. + :param target_lang: Override target language of glossary. + """ + if not isinstance(glossary, GlossaryInfo): + raise ValueError( + "This function expects the glossary parameter to be an " + "instance of GlossaryInfo. Use get_glossary() to obtain a " + "GlossaryInfo using the glossary ID of an existing " + "glossary. Alternatively, use translate_text() and " + "specify the glossary ID using the glossary parameter. " ) - request_data: dict = {"text": text} - if target_lang: - request_data["target_lang"] = target_lang - if style: - request_data["writing_style"] = style - if tone: - request_data["tone"] = tone + if target_lang is None: + target_lang = glossary.target_lang + if target_lang == "EN": + target_lang = "EN-GB" + + return self.translate_text( + text, + source_lang=glossary.source_lang, + target_lang=target_lang, + glossary=glossary, + **kwargs, + ) + + # ------------------------------------------------------------------ + # Public API: document translation + # ------------------------------------------------------------------ - status, content, json = self._api_call( - "v2/write/rephrase", json=request_data + def translate_document_from_filepath( + self, + input_path: Union[str, pathlib.PurePath], + output_path: Union[str, pathlib.PurePath], + *, + source_lang: Optional[str] = None, + target_lang: str, + formality: Union[str, Formality] = Formality.DEFAULT, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + timeout_s: Optional[int] = None, + extra_body_parameters: Optional[dict] = None, + ) -> DocumentStatus: + """Upload document at given input path, translate, and download.""" + in_ext = pathlib.PurePath(input_path).suffix.lower() + out_ext = pathlib.PurePath(output_path).suffix.lower() + output_format = None if in_ext == out_ext else out_ext[1:] + + with open(input_path, "rb") as in_file: + with open(output_path, "wb") as out_file: + try: + return self.translate_document( + in_file, + out_file, + target_lang=target_lang, + source_lang=source_lang, + formality=formality, + glossary=glossary, + output_format=output_format, + timeout_s=timeout_s, + extra_body_parameters=extra_body_parameters, + ) + except Exception: + out_file.close() + os.unlink(output_path) + raise + + def translate_document( + self, + input_document: Union[TextIO, BinaryIO, Any], + output_document: Union[TextIO, BinaryIO, Any], + *, + source_lang: Optional[str] = None, + target_lang: str, + formality: Union[str, Formality] = Formality.DEFAULT, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + filename: Optional[str] = None, + output_format: Optional[str] = None, + timeout_s: Optional[int] = None, + extra_body_parameters: Optional[dict] = None, + ) -> DocumentStatus: + """Upload document, translate, and download result.""" + handle = self.translate_document_upload( + input_document, + target_lang=target_lang, + source_lang=source_lang, + formality=formality, + glossary=glossary, + filename=filename, + output_format=output_format, + extra_body_parameters=extra_body_parameters, ) - self._raise_for_status(status, content, json) + try: + status = self.translate_document_wait_until_done(handle, timeout_s) + if status.ok: + self.translate_document_download(handle, output_document) + except Exception as e: + raise DocumentTranslationException(str(e), handle) from e + + if not status.ok: + error_message = status.error_message or "unknown error" + raise DocumentTranslationException( + f"Error occurred while translating document: {error_message}", + handle, + ) + return status - improvements = ( - json.get("improvements", []) - if (json and isinstance(json, dict)) - else [] + def translate_document_upload( + self, + input_document: Union[TextIO, BinaryIO, str, bytes, Any], + *, + source_lang: Optional[str] = None, + target_lang: str, + formality: Union[str, Formality, None] = None, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + filename: Optional[str] = None, + output_format: Optional[str] = None, + extra_body_parameters: Optional[dict] = None, + ) -> DocumentHandle: + """Upload a document for translation; return the document handle.""" + request = _build_document_upload_request( + self._server_url, + input_document, + target_lang=target_lang, + source_lang=source_lang, + formality=formality, + glossary=glossary, + filename=filename, + output_format=output_format, + extra_body_parameters=extra_body_parameters, ) - output = [] - for improvement in improvements: - text = improvement.get("text", "") if improvement else "" - detected_source_language = ( - improvement.get("detected_source_language", "") - if improvement - else "" + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_document_upload_response(response) + + def translate_document_get_status( + self, handle: DocumentHandle + ) -> DocumentStatus: + """Get the translation status for the given document handle.""" + request = _build_document_status_request(self._server_url, handle) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_document_status_response(response, handle) + + def translate_document_wait_until_done( + self, + handle: DocumentHandle, + timeout_s: Optional[int] = None, + ) -> DocumentStatus: + """Poll document status until translation completes or fails.""" + status = self.translate_document_get_status(handle) + start_time_s = time.time() + while status.ok and not status.done: + if ( + timeout_s is not None + and time.time() - start_time_s > timeout_s + ): + raise DeepLException( + f"Manual timeout of {timeout_s}s exceeded for" + " document translation", + should_retry=False, + ) + secs = ( + min(status.seconds_remaining, 5.0) + if status.seconds_remaining is not None + else 5.0 ) - target_language = ( - improvement.get("target_language", "") if improvement else "" + util.log_info( + f"Rechecking document translation status " + f"after sleeping for {secs:.3f} seconds." ) - output.append( - WriteResult(text, detected_source_language, target_language) + self._sleep_fn(secs) + status = self.translate_document_get_status(handle) + return status + + def translate_document_download( + self, + handle: DocumentHandle, + output_file: Union[TextIO, BinaryIO, Any, None] = None, + chunk_size: int = 1, + ) -> Optional[Any]: + """Download translated document. + + :param handle: DocumentHandle from translate_document_upload. + :param output_file: (Optional) File-like object to write content to. + If None, returns a streaming response; call iter_content() on it. + :param chunk_size: Chunk size in bytes when writing to output_file. + :return: None if output_file provided, otherwise streaming response. + """ + request = _build_document_download_request(self._server_url, handle) + streaming = self._send_streaming_with_backoff(request) + + if not (200 <= streaming.status_code < 400): + # Consume body to get error details. + content = b"".join(streaming.iter_content(65536)) + error_response = HttpResponse( + status_code=streaming.status_code, + headers=dict(streaming.headers), + content=content, ) + self._raise_for_status(error_response, downloading_document=True) + + if output_file is not None: + for chunk in streaming.iter_content(chunk_size=chunk_size): + output_file.write(chunk) # type: ignore[arg-type] + return None + return streaming + + # ------------------------------------------------------------------ + # Public API: usage and languages + # ------------------------------------------------------------------ + + def get_usage(self) -> Usage: + """Request the current API usage.""" + request = _build_get_usage_request(self._server_url) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_usage_response(response) + + def get_source_languages(self, skip_cache: bool = False) -> List[Language]: + """Request the list of available source languages. + + :param skip_cache: Deprecated, no-op. + """ + request = _build_get_source_languages_request(self._server_url) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_source_languages_response(response) + + def get_target_languages(self, skip_cache: bool = False) -> List[Language]: + """Request the list of available target languages. + + :param skip_cache: Deprecated, no-op. + """ + request = _build_get_target_languages_request(self._server_url) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_target_languages_response(response) + + def get_glossary_languages(self) -> List[GlossaryLanguagePair]: + """Request the list of language pairs supported for glossaries.""" + request = _build_get_glossary_languages_request(self._server_url) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_glossary_languages_response(response) + + # ------------------------------------------------------------------ + # Public API: classic (v2) glossaries + # ------------------------------------------------------------------ + + def create_glossary( + self, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + entries: Dict[str, str], + ) -> GlossaryInfo: + """Create a v2 glossary with given name and entries.""" + if not entries: + raise ValueError("glossary entries must not be empty") + return self._create_glossary( + name, + source_lang, + target_lang, + "tsv", + util.convert_dict_to_tsv(entries), + ) + + def create_glossary_from_csv( + self, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + csv_data: Union[TextIO, BinaryIO, str, bytes, Any], + ) -> GlossaryInfo: + """Create a v2 glossary from CSV data.""" + entries = ( + csv_data if isinstance(csv_data, (str, bytes)) else csv_data.read() + ) + if not isinstance(entries, (bytes, str)): + raise ValueError("Entries of the glossary are invalid") + return self._create_glossary( + name, source_lang, target_lang, "csv", entries + ) - return output if multi_input else output[0] + def _create_glossary( + self, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + entries_format: str, + entries: Union[str, bytes], + ) -> GlossaryInfo: + request = _build_create_glossary_request( + self._server_url, + name, + source_lang, + target_lang, + entries_format, + entries, + ) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_create_glossary_response(response) + + def get_glossary(self, glossary_id: str) -> GlossaryInfo: + """Retrieve GlossaryInfo for the given glossary ID.""" + request = _build_get_glossary_request(self._server_url, glossary_id) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_get_glossary_response(response) + + def list_glossaries(self) -> List[GlossaryInfo]: + """Retrieve GlossaryInfo for all available glossaries.""" + request = _build_list_glossaries_request(self._server_url) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_list_glossaries_response(response) + + def get_glossary_entries(self, glossary: Union[str, GlossaryInfo]) -> dict: + """Retrieve the entries of the specified glossary as a dict.""" + request = _build_get_glossary_entries_request( + self._server_url, glossary + ) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_get_glossary_entries_response(response) + + def delete_glossary(self, glossary: Union[str, GlossaryInfo]) -> None: + """Delete the specified glossary.""" + request = _build_delete_glossary_request(self._server_url, glossary) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + + # ------------------------------------------------------------------ + # Public API: multilingual (v3) glossaries + # ------------------------------------------------------------------ def create_multilingual_glossary( self, name: str, glossary_dicts: List[MultilingualGlossaryDictionaryEntries], ) -> MultilingualGlossaryInfo: - """Creates a glossary with given name with all of the specified - dictionaries, each with their own language pair and entries. The - glossary may be used in the translate_text functions. - - The available glossary language pairs can be queried using - get_glossary_languages(). Glossaries apply to languages, not specific - language variants. A glossary for a language applies to any variant - of that language: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function requires the glossary entries for each dictionary to be - provided as a dictionary of source-target terms. To create a glossary - from a CSV file downloaded from the DeepL website, see - create_glossary_from_csv(). - - :param name: user-defined name to attach to glossary. - :param dictionaries: a list of MultilingualGlossaryDictionaryEntries - which each contains entries for a particular language pair - :return: GlossaryInfo containing information about created glossary. - - :raises ValueError: If the glossary name is empty, or entries are - empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ - if any(map(lambda d: not d.entries, glossary_dicts)): + """Create a multilingual glossary.""" + if any(not d.entries for d in glossary_dicts): raise ValueError("glossary entries must not be empty") - return self._create_multilingual_glossary(name, glossary_dicts) def create_multilingual_glossary_from_csv( @@ -164,36 +877,8 @@ def create_multilingual_glossary_from_csv( target_lang: str, csv_data: Union[TextIO, BinaryIO, str, bytes, Any], ) -> MultilingualGlossaryInfo: - """Creates a glossary with given name for the source and target - languages, containing the entries in the given CSV data. - The glossary may be used in the translate_text functions. - - The available glossary language pairs can be queried using - get_glossary_languages(). Glossaries apply to languages, not specific - language variants. A glossary for a language applies to any variant - of that language: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function allows you to upload a glossary CSV file that you have - downloaded from the DeepL website. - - Information about the expected CSV format can be found in the API - documentation: https://developers.deepl.com/docs/api-reference/glossaries#csv-comma-separated-values # noqa - - :param name: user-defined name to attach to glossary. - :param source_lang: Language of source entries. - :param target_lang: Language of target entries. - :param csv_data: CSV data containing glossary entries, either as a - file-like object or string or bytes containing file content. - :return: GlossaryInfo containing information about created glossary. - - :raises ValueError: If the glossary name is empty, or entries are - empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ + """Create a multilingual glossary from CSV data.""" entries = util.convert_csv_to_dict(csv_data) - dictionaries = [ MultilingualGlossaryDictionaryEntries( source_lang, target_lang, entries @@ -206,99 +891,40 @@ def _create_multilingual_glossary( name: str, glossary_dicts: List[MultilingualGlossaryDictionaryEntries], ) -> MultilingualGlossaryInfo: - if not name: - raise ValueError("glossary name must not be empty") - - req_glossary_dicts = [] - # glossaries are only supported for base language types - for glossary_dict in glossary_dicts: - req_glossary_dict = { - "source_lang": Language.remove_regional_variant( - glossary_dict.source_lang - ), - "target_lang": Language.remove_regional_variant( - glossary_dict.target_lang - ), - "entries": util.convert_dict_to_tsv(glossary_dict.entries), - "entries_format": "tsv", - } - req_glossary_dicts.append(req_glossary_dict) - - request_data = { - "name": name, - "dictionaries": req_glossary_dicts, - } - - status, content, json = self._api_call( - "v3/glossaries", json=request_data + request = _build_create_multilingual_glossary_request( + self._server_url, name, glossary_dicts ) - self._raise_for_status(status, content, json, glossary=True) - return MultilingualGlossaryInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) def update_multilingual_glossary_name( self, glossary: Union[str, MultilingualGlossaryInfo], name: str, ) -> MultilingualGlossaryInfo: - """Updates the name of a glossary with the provided name. - - :param glossary: GlossaryInfo or ID of glossary to update. - :param name: The new name of the glossary - :return: MultilingualGlossaryInfo containing information about updated - glossary. - - :raises ValueError: If the name is empty or invalid. - :raises DeepLException: If the glossary cannot be found. - """ - if not name: - raise ValueError("glossary name must not be empty") - - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - request_data = {"name": name} - - status, content, json = self._api_call( - f"v3/glossaries/{glossary}", method="PATCH", json=request_data + """Update the name of a multilingual glossary.""" + request = _build_update_multilingual_glossary_name_request( + self._server_url, glossary, name ) - self._raise_for_status(status, content, json, glossary=True) - return MultilingualGlossaryInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) def update_multilingual_glossary_dictionary( self, glossary: Union[str, MultilingualGlossaryInfo], glossary_dict: MultilingualGlossaryDictionaryEntries, ) -> MultilingualGlossaryInfo: - """Updates or creates a glossary dictionary with given glossary - dictionary with its entries for the source and target languages. - Either updates the glossary's entries if they exist for the - given language pair, or adds any new ones to the dictionary if not. - - The available glossary language pairs can be queried using - get_glossary_languages(). Glossaries apply to languages, not specific - language variants. A glossary for a language applies to any variant - of that language: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function requires the glossary entries to be provided as a - dictionary of source-target terms. To create a glossary from a CSV file - downloaded from the DeepL website, see create_glossary_from_csv(). - - :param glossary: GlossaryInfo or ID of glossary to update. - :param glossary_dict: The new or updated glossary dictionary - :return: MultilingualGlossaryInfo containing information about updated - glossary. - - :raises ValueError: If the glossary entries are empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ + """Update or create a dictionary in a multilingual glossary.""" if not glossary_dict or not glossary_dict.entries: raise ValueError("glossary entries must not be empty") - - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - - return self._update_multilingual_glossary(glossary, [glossary_dict]) + request = _build_update_multilingual_glossary_dict_request( + self._server_url, glossary, [glossary_dict] + ) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) def update_multilingual_glossary_dictionary_from_csv( self, @@ -307,121 +933,38 @@ def update_multilingual_glossary_dictionary_from_csv( target_lang: str, csv_data: Union[TextIO, BinaryIO, str, bytes, Any], ) -> MultilingualGlossaryInfo: - """Updates or creates a glossary dictionary with given entries in - CSV formatting for the source and target languages. Either updates - entries if they exist for the given language pair, or adds new ones - to the dictionary if not. - - The available glossary language pairs can be queried using - get_glossary_languages(). Glossaries apply to languages, not specific - language variants. A glossary for a language applies to any variant - of that language: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function allows you to upload a glossary CSV file that you have - downloaded from the DeepL website. - - Information about the expected CSV format can be found in the API - documentation: https://www.deepl.com/docs-api/managing-glossaries/supported-glossary-formats/ # noqa - - :param glossary: MultilingualGlossaryInfo or ID of glossary to update. - :param source_lang: Language of source entries. - :param target_lang: Language of target entries. - :param csv_data: CSV data containing glossary entries, either as a - file-like object or string or bytes containing file content. - :return: MultilingualGlossaryInfo containing information about updated - glossary. - - :raises ValueError: If the glossary entries are empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ + """Update or create a dictionary from CSV data.""" entries = util.convert_csv_to_dict(csv_data) - - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - dictionaries = [ MultilingualGlossaryDictionaryEntries( source_lang, target_lang, entries ) ] - return self._update_multilingual_glossary(glossary, dictionaries) - - def _update_multilingual_glossary( - self, - glossary_id: str, - dictionaries: List[MultilingualGlossaryDictionaryEntries], - ) -> MultilingualGlossaryInfo: - if not glossary_id: - raise ValueError("glossary id must not be empty") - - req_glossary_dicts = [] - # glossaries are only supported for base language types - for glossary_dict in dictionaries: - req_glossary_dict = { - "source_lang": Language.remove_regional_variant( - glossary_dict.source_lang - ), - "target_lang": Language.remove_regional_variant( - glossary_dict.target_lang - ), - "entries": util.convert_dict_to_tsv(glossary_dict.entries), - "entries_format": "tsv", - } - req_glossary_dicts.append(req_glossary_dict) - - request_data = {} - - if dictionaries: - request_data["dictionaries"] = req_glossary_dicts - - status, content, json = self._api_call( - f"v3/glossaries/{glossary_id}", method="PATCH", json=request_data + request = _build_update_multilingual_glossary_dict_request( + self._server_url, glossary, dictionaries ) - self._raise_for_status(status, content, json, glossary=True) - - return MultilingualGlossaryInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) def replace_multilingual_glossary_dictionary( self, glossary: Union[str, MultilingualGlossaryInfo], glossary_dict: MultilingualGlossaryDictionaryEntries, ) -> MultilingualGlossaryDictionaryInfo: - """Replaces a glossary dictionary with given entries for the - source and target languages. - - The available glossary language pairs can be queried using - get_glossary_languages(). Glossaries apply to languages, not specific - language variants. A glossary for a language applies to any variant - of that language: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function requires the glossary entries to be provided as a - dictionary of source-target terms. To create a glossary from a CSV file - downloaded from the DeepL website, see create_glossary_from_csv(). - - :param glossary: GlossaryInfo or ID of glossary to update. - :param glossary_dict: The new glossary dictionary - :return: MultilingualGlossaryDictionaryInfo containing information - about the updated dictionary. - - :raises ValueError: If the glossary entries are empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ + """Replace a dictionary in a multilingual glossary.""" if not glossary_dict or not glossary_dict.entries: raise ValueError("glossary entries must not be empty") - - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - - return self._replace_multilingual_glossary_dictionary( + request = _build_replace_multilingual_glossary_dict_request( + self._server_url, glossary, glossary_dict.source_lang, glossary_dict.target_lang, glossary_dict.entries, ) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_dict_response(response) def replace_multilingual_glossary_dictionary_from_csv( self, @@ -430,107 +973,32 @@ def replace_multilingual_glossary_dictionary_from_csv( target_lang: str, csv_data: Union[TextIO, BinaryIO, str, bytes, Any], ) -> MultilingualGlossaryDictionaryInfo: - """Replaces a glossary dictionary with given CSV formatted entries - for the source and target languages. - - The available glossary language pairs can be queried using - get_glossary_languages(). Glossaries apply to languages, not specific - language variants. A glossary for a language applies to any variant - of that language: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function allows you to upload a glossary CSV file that you have - downloaded from the DeepL website. - - Information about the expected CSV format can be found in the API - documentation: https://www.deepl.com/docs-api/managing-glossaries/supported-glossary-formats/ # noqa - - :param glossary: MultilingualGlossaryInfo or ID of glossary to update. - :param source_lang: Language of source entries. - :param target_lang: Language of target entries. - :param csv_data: CSV data containing glossary entries, either as a - file-like object or string or bytes containing file content. - :return: MultilingualGlossaryDictionaryInfo containing information - about updated dictionary. - - :raises ValueError: If the glossary entries are empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ + """Replace a glossary dictionary from CSV data.""" entries = util.convert_csv_to_dict(csv_data) - - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - - return self._replace_multilingual_glossary_dictionary( - glossary, source_lang, target_lang, entries + request = _build_replace_multilingual_glossary_dict_request( + self._server_url, glossary, source_lang, target_lang, entries ) - - def _replace_multilingual_glossary_dictionary( - self, - glossary_id: str, - source_lang: str, - target_lang: str, - entries: Dict[str, str], - ) -> MultilingualGlossaryDictionaryInfo: - if not glossary_id: - raise ValueError("glossary id must not be empty") - - # glossaries are only supported for base language types - source_lang = Language.remove_regional_variant(source_lang) - target_lang = Language.remove_regional_variant(target_lang) - - request_data = { - "source_lang": source_lang, - "target_lang": target_lang, - "entries": util.convert_dict_to_tsv(entries), - "entries_format": "tsv", - } - - status, content, json = self._api_call( - f"v3/glossaries/{glossary_id}/dictionaries", - method="PUT", - json=request_data, - ) - self._raise_for_status(status, content, json, glossary=True) - return MultilingualGlossaryDictionaryInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_dict_response(response) def get_multilingual_glossary( self, glossary_id: str ) -> MultilingualGlossaryInfo: - """Retrieves MultilingualGlossaryInfo for the glossary with specified - ID. - - :param glossary_id: ID of glossary to retrieve. - :return: MultilingualGlossaryInfo with information about specified - glossary. - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - """ - status, content, json = self._api_call( - f"v3/glossaries/{glossary_id}", method="GET" + """Retrieve a multilingual glossary by ID.""" + request = _build_get_multilingual_glossary_request( + self._server_url, glossary_id ) - self._raise_for_status(status, content, json, glossary=True) - return MultilingualGlossaryInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) def list_multilingual_glossaries(self) -> List[MultilingualGlossaryInfo]: - """Retrieves a list of MultilingualGlossaryInfo for all available - glossaries. - - :return: list of MultilingualGlossaryInfos for all available - glossaries. - """ - status, content, json = self._api_call("v3/glossaries", method="GET") - self._raise_for_status(status, content, json, glossary=True) - glossaries = ( - json.get("glossaries", []) - if (json and isinstance(json, dict)) - else [] - ) - return [ - MultilingualGlossaryInfo.from_json(glossary) - for glossary in glossaries - ] + """List all available multilingual glossaries.""" + request = _build_list_multilingual_glossaries_request(self._server_url) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_list_multilingual_glossaries_response(response) def get_multilingual_glossary_entries( self, @@ -538,49 +1006,23 @@ def get_multilingual_glossary_entries( source_lang: str, target_lang: str, ) -> MultilingualGlossaryDictionaryEntriesResponse: - """Retrieves the entries for a given source and target language in the - specified glossary. - - :param glossary: MultilingualGlossaryInfo or ID of glossary to - retrieve. - :param source_lang: Language of source terms. - :param target_lang: Language of target terms. - :return: MultilingualGlossaryDictionaryEntriesResponse object - containing the entries. - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - :raises DeepLException: If the glossary could not be retrieved - in the right format. - """ - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - source_lang = Language.remove_regional_variant(source_lang) - target_lang = Language.remove_regional_variant(target_lang) - - status, content, json = self._api_call( - f"v3/glossaries/{glossary}/entries?source_lang={source_lang}&target_lang={target_lang}", # noqa: E501 - method="GET", + """Retrieve entries for a language pair in a multilingual glossary.""" + request = _build_get_multilingual_glossary_entries_request( + self._server_url, glossary, source_lang, target_lang ) - self._raise_for_status(status, content, json, glossary=True) - return MultilingualGlossaryDictionaryEntriesResponse.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_entries_response(response) def delete_multilingual_glossary( self, glossary: Union[str, MultilingualGlossaryInfo] ) -> None: - """Deletes specified glossary. - - :param glossary: MultilingualGlossaryInfo or ID of glossary to delete. - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - """ - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id - - status, content, json = self._api_call( - f"v3/glossaries/{glossary}", - method="DELETE", + """Delete a multilingual glossary.""" + request = _build_delete_multilingual_glossary_request( + self._server_url, glossary ) - self._raise_for_status(status, content, json, glossary=True) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) def delete_multilingual_glossary_dictionary( self, @@ -589,42 +1031,47 @@ def delete_multilingual_glossary_dictionary( source_lang: Optional[str] = None, target_lang: Optional[str] = None, ) -> None: - """Deletes specified glossary dictionary. - - :param glossary: GlossaryInfo or ID of glossary containing the - dictionary to delete - :param dictionary: The dictionary to delete. Either the - MultilingualGlossaryDictionaryInfo or both the source_lang and - target_lang can be provided to identify the dictionary. However, - if both are provided, the dictionary takes precendence over - source_lang and target_lang. - :param source_lang: Optional parameter representing the source language - of the dictionary - :param target_lang: Optional parameter representing the target language - of the dictionary - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - :raises ValueError: If the dictionary or both the source_lang and - target_lang were not provided - """ - if isinstance(glossary, MultilingualGlossaryInfo): - glossary = glossary.glossary_id + """Delete a specific dictionary from a multilingual glossary.""" + request = _build_delete_multilingual_glossary_dict_request( + self._server_url, glossary, source_lang, target_lang, dictionary + ) + response = self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) - if not dictionary and not (source_lang and target_lang): - raise ValueError( - "must provide dictionary or both source_lang and target_lang" - ) + # ------------------------------------------------------------------ + # Public API: Write (rephrase) + # ------------------------------------------------------------------ - if dictionary: - source_lang = dictionary.source_lang - target_lang = dictionary.target_lang + def rephrase_text( + self, + text: Union[str, Iterable[str]], + *, + target_lang: Union[None, str, Language] = None, + style: Optional[str] = None, + tone: Optional[str] = None, + ) -> Union[WriteResult, List[WriteResult]]: + """Improve the text(s) using the Write API. - req_url = f"v3/glossaries/{glossary}/dictionaries?source_lang={source_lang}&target_lang={target_lang}" # noqa: E501 - status, content, json = self._api_call( - req_url, - method="DELETE", + :param text: Text to improve. + :param target_lang: (Optional) Target language code. + :param style: (Optional) Writing style. Mutually exclusive with tone. + :param tone: (Optional) Tone. Mutually exclusive with style. + :return: Single WriteResult or list of WriteResult objects. + """ + request, multi_input = _build_rephrase_text_request( + self._server_url, + text, + target_lang=target_lang, + style=style, + tone=tone, ) - self._raise_for_status(status, content, json, glossary=True) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_rephrase_text_response(response, multi_input) + + # ------------------------------------------------------------------ + # Public API: style rules + # ------------------------------------------------------------------ def get_all_style_rules( self, @@ -632,38 +1079,13 @@ def get_all_style_rules( page_size: Optional[int] = None, detailed: Optional[bool] = None, ) -> List[StyleRuleInfo]: - """Retrieves a list of StyleRuleInfo for all available style rules. - - :param page: Page number for pagination, 0-indexed (optional). - :param page_size: Number of items per page (optional). - :param detailed: Whether to include detailed configuration rules - (optional). - :return: List of StyleRuleInfo objects for all available style rules. - """ - params = {} - if page is not None: - params["page"] = str(page) - if page_size is not None: - params["page_size"] = str(page_size) - if detailed is not None: - params["detailed"] = str(detailed).lower() - - endpoint = "v3/style_rules" - if params: - query_string = urllib.parse.urlencode(params) - endpoint += f"?{query_string}" - - status, content, json = self._api_call(endpoint, method="GET") - self._raise_for_status(status, content, json) - - style_rules = ( - json.get("style_rules", []) - if (json and isinstance(json, dict)) - else [] - ) - return [ - StyleRuleInfo.from_json(style_rule) for style_rule in style_rules - ] + """Retrieve all available style rules.""" + request = _build_get_style_rules_request( + self._server_url, page, page_size, detailed + ) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_style_rules_response(response) def create_style_rule( self, @@ -672,113 +1094,86 @@ def create_style_rule( configured_rules: Optional[dict] = None, custom_instructions: Optional[List[dict]] = None, ) -> StyleRuleInfo: - """Creates a new style rule. - - :param name: Name for the style rule. - :param language: Language code for the style rule. - :param configured_rules: Optional dict of configured rules. - :param custom_instructions: Optional list of custom instruction dicts. - :return: StyleRuleInfo for the created style rule. - """ + """Create a new style rule.""" if not name: raise ValueError("name must not be empty") if not language: raise ValueError("language must not be empty") - - request_data: Dict[str, Any] = {"name": name, "language": language} - if configured_rules is not None: - request_data["configured_rules"] = configured_rules - if custom_instructions is not None: - request_data["custom_instructions"] = custom_instructions - - status, content, json = self._api_call( - "v3/style_rules", json=request_data + request = _build_create_style_rule_request( + self._server_url, + name, + language, + configured_rules, + custom_instructions, ) - self._raise_for_status(status, content, json) - return StyleRuleInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_style_rule_response(response) def get_style_rule( self, style_rule: Union[str, StyleRuleInfo], ) -> StyleRuleInfo: - """Retrieves a single style rule by ID. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :return: StyleRuleInfo for the requested style rule. - """ + """Retrieve a single style rule by ID.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: raise ValueError("style_rule must not be empty") - status, content, json = self._api_call( - f"v3/style_rules/{style_rule}", method="GET" - ) - self._raise_for_status(status, content, json) - return StyleRuleInfo.from_json(json) + request = _build_get_style_rule_request(self._server_url, style_rule) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_style_rule_response(response) def update_style_rule_name( self, style_rule: Union[str, StyleRuleInfo], name: str, ) -> StyleRuleInfo: - """Updates the name of a style rule. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :param name: New name for the style rule. - :return: Updated StyleRuleInfo. - """ + """Update the name of a style rule.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: raise ValueError("style_rule must not be empty") if not name: raise ValueError("name must not be empty") - request_data = {"name": name} - status, content, json = self._api_call( - f"v3/style_rules/{style_rule}", method="PATCH", json=request_data + request = _build_update_style_rule_name_request( + self._server_url, style_rule, name ) - self._raise_for_status(status, content, json) - return StyleRuleInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_style_rule_response(response) def delete_style_rule( self, style_rule: Union[str, StyleRuleInfo], ) -> None: - """Deletes a style rule. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - """ + """Delete a style rule.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: raise ValueError("style_rule must not be empty") - status, content, json = self._api_call( - f"v3/style_rules/{style_rule}", method="DELETE" + request = _build_delete_style_rule_request( + self._server_url, style_rule ) - self._raise_for_status(status, content, json) + response = self._send_with_backoff(request) + self._raise_for_status(response) def update_style_rule_configured_rules( self, style_rule: Union[str, StyleRuleInfo], configured_rules: dict, ) -> StyleRuleInfo: - """Updates the configured rules of a style rule. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :param configured_rules: Dict of configured rules to set. - :return: Updated StyleRuleInfo. - """ + """Replace the configured rules of a style rule.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: raise ValueError("style_rule must not be empty") - status, content, json = self._api_call( - f"v3/style_rules/{style_rule}/configured_rules", - method="PUT", - json=configured_rules, + request = _build_update_style_rule_configured_rules_request( + self._server_url, style_rule, configured_rules ) - self._raise_for_status(status, content, json) - return StyleRuleInfo.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_style_rule_response(response) def create_style_rule_custom_instruction( self, @@ -787,14 +1182,7 @@ def create_style_rule_custom_instruction( prompt: str, source_language: Optional[str] = None, ) -> CustomInstruction: - """Creates a custom instruction for a style rule. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :param label: Label for the custom instruction. - :param prompt: Prompt text for the custom instruction. - :param source_language: Optional source language code. - :return: Created CustomInstruction. - """ + """Create a custom instruction for a style rule.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: @@ -803,43 +1191,31 @@ def create_style_rule_custom_instruction( raise ValueError("label must not be empty") if not prompt: raise ValueError("prompt must not be empty") - request_data = {"label": label, "prompt": prompt} - if source_language is not None: - request_data["source_language"] = source_language - status, content, json = self._api_call( - f"v3/style_rules/{style_rule}/custom_instructions", - json=request_data, + request = _build_create_style_rule_custom_instruction_request( + self._server_url, style_rule, label, prompt, source_language ) - self._raise_for_status(status, content, json) - return CustomInstruction.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_custom_instruction_response(response) def get_style_rule_custom_instruction( self, style_rule: Union[str, StyleRuleInfo], instruction_id: str, ) -> CustomInstruction: - """Retrieves a custom instruction by ID. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :param instruction_id: ID of the custom instruction. - :return: CustomInstruction. - """ + """Retrieve a custom instruction by ID.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: raise ValueError("style_rule must not be empty") if not instruction_id: raise ValueError("instruction_id must not be empty") - url = ( - f"v3/style_rules/{style_rule}" - f"/custom_instructions/{instruction_id}" - ) - status, content, json = self._api_call( - url, - method="GET", + request = _build_get_style_rule_custom_instruction_request( + self._server_url, style_rule, instruction_id ) - self._raise_for_status(status, content, json) - return CustomInstruction.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_custom_instruction_response(response) def update_style_rule_custom_instruction( self, @@ -849,15 +1225,7 @@ def update_style_rule_custom_instruction( prompt: str, source_language: Optional[str] = None, ) -> CustomInstruction: - """Updates a custom instruction. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :param instruction_id: ID of the custom instruction. - :param label: New label for the custom instruction. - :param prompt: New prompt text for the custom instruction. - :param source_language: Optional source language code. - :return: Updated CustomInstruction. - """ + """Update a custom instruction.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: @@ -868,46 +1236,35 @@ def update_style_rule_custom_instruction( raise ValueError("label must not be empty") if not prompt: raise ValueError("prompt must not be empty") - request_data = {"label": label, "prompt": prompt} - if source_language is not None: - request_data["source_language"] = source_language - url = ( - f"v3/style_rules/{style_rule}" - f"/custom_instructions/{instruction_id}" + request = _build_update_style_rule_custom_instruction_request( + self._server_url, + style_rule, + instruction_id, + label, + prompt, + source_language, ) - status, content, json = self._api_call( - url, - method="PUT", - json=request_data, - ) - self._raise_for_status(status, content, json) - return CustomInstruction.from_json(json) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_custom_instruction_response(response) def delete_style_rule_custom_instruction( self, style_rule: Union[str, StyleRuleInfo], instruction_id: str, ) -> None: - """Deletes a custom instruction from a style rule. - - :param style_rule: Style rule ID string or StyleRuleInfo object. - :param instruction_id: ID of the custom instruction to delete. - """ + """Delete a custom instruction from a style rule.""" if isinstance(style_rule, StyleRuleInfo): style_rule = style_rule.style_id if not style_rule: raise ValueError("style_rule must not be empty") if not instruction_id: raise ValueError("instruction_id must not be empty") - url = ( - f"v3/style_rules/{style_rule}" - f"/custom_instructions/{instruction_id}" + request = _build_delete_style_rule_custom_instruction_request( + self._server_url, style_rule, instruction_id ) - status, content, json = self._api_call( - url, - method="DELETE", - ) - self._raise_for_status(status, content, json) + response = self._send_with_backoff(request) + self._raise_for_status(response) def list_translation_memories( self, @@ -922,25 +1279,9 @@ def list_translation_memories( :param page_size: Number of items per page (optional). :return: List of TranslationMemoryInfo objects. """ - params = {} - if page is not None: - params["page"] = str(page) - if page_size is not None: - params["page_size"] = str(page_size) - - endpoint = "v3/translation_memories" - if params: - query_string = urllib.parse.urlencode(params) - endpoint += f"?{query_string}" - - status, content, json = self._api_call(endpoint, method="GET") - self._raise_for_status(status, content, json) - - translation_memories = ( - json.get("translation_memories", []) - if (json and isinstance(json, dict)) - else [] - ) - return [ - TranslationMemoryInfo.from_json(tm) for tm in translation_memories - ] + request = _build_list_translation_memories_request( + self._server_url, page=page, page_size=page_size + ) + response = self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_list_translation_memories_response(response) diff --git a/deepl/deepl_client_async.py b/deepl/deepl_client_async.py new file mode 100644 index 0000000..e15db8f --- /dev/null +++ b/deepl/deepl_client_async.py @@ -0,0 +1,987 @@ +# Copyright 2026 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import asyncio +import json as json_module +import os +import pathlib +import time +import warnings +from typing import ( + Any, + Awaitable, + BinaryIO, + Callable, + Dict, + Iterable, + List, + Optional, + TextIO, + Union, +) + +from .api_data import ( + DocumentHandle, + DocumentStatus, + Formality, + GlossaryInfo, + GlossaryLanguagePair, + Language, + ModelType, + MultilingualGlossaryDictionaryEntries, + MultilingualGlossaryDictionaryEntriesResponse, + MultilingualGlossaryDictionaryInfo, + MultilingualGlossaryInfo, + SplitSentences, + StyleRuleInfo, + TextResult, + Usage, + WriteResult, +) +from ._backoff_timer import BackoffTimer +from ._client_base import _ClientBase +from ._http_types import ( + HttpRequest, + HttpResponse, + SslConfig, +) +from ._methods import ( + _build_create_glossary_request, + _build_create_multilingual_glossary_request, + _build_delete_glossary_request, + _build_delete_multilingual_glossary_dict_request, + _build_delete_multilingual_glossary_request, + _build_document_download_request, + _build_document_status_request, + _build_document_upload_request, + _build_get_glossary_entries_request, + _build_get_glossary_languages_request, + _build_get_glossary_request, + _build_get_multilingual_glossary_entries_request, + _build_get_multilingual_glossary_request, + _build_get_source_languages_request, + _build_get_style_rules_request, + _build_get_target_languages_request, + _build_get_usage_request, + _build_list_glossaries_request, + _build_list_multilingual_glossaries_request, + _build_rephrase_text_request, + _build_replace_multilingual_glossary_dict_request, + _build_translate_text_request, + _build_update_multilingual_glossary_dict_request, + _build_update_multilingual_glossary_name_request, + _parse_create_glossary_response, + _parse_document_status_response, + _parse_document_upload_response, + _parse_get_glossary_entries_response, + _parse_get_glossary_languages_response, + _parse_get_glossary_response, + _parse_get_source_languages_response, + _parse_get_style_rules_response, + _parse_get_target_languages_response, + _parse_get_usage_response, + _parse_list_glossaries_response, + _parse_list_multilingual_glossaries_response, + _parse_multilingual_glossary_dict_response, + _parse_multilingual_glossary_entries_response, + _parse_multilingual_glossary_response, + _parse_rephrase_text_response, + _parse_translate_text_response, +) +from .exceptions import ( + ConnectionException, + DeepLException, + DocumentTranslationException, +) +from .ihttp_client import AsyncHttpClientProtocol +from .retry_config import RetryConfig +from . import util + +_DEFAULT_RETRY_CONFIG = RetryConfig() + + +class DeepLClientAsync(_ClientBase): + """Async client for the DeepL API. + + Mirrors :class:`DeepLClient` but all network methods are coroutines. + Requires :mod:`aiohttp`; install with ``pip install deepl[async]``. + + :param auth_key: Authentication key as found in your DeepL API account. + :param server_url: (Optional) Base URL of DeepL API. + :param proxy: (Optional) Proxy URL string or dict with ``"http"``/ + ``"https"`` keys. Forwarded to :class:`AioHttpClient`. + :param send_platform_info: (Optional) Include OS/Python info in + User-Agent. + :param verify_ssl: (Optional) SSL certificate verification config. + :param http_client: (Optional) Custom async HTTP client conforming to + :class:`~deepl.AsyncHttpClientProtocol`. When supplied, ``proxy`` + and ``verify_ssl`` must not be set. + :param retry_config: (Optional) Backoff/retry settings. + :param skip_language_check: Deprecated, no-op. + """ + + def __init__( + self, + auth_key: str, + *, + server_url: Optional[str] = None, + proxy: Union[Dict, str, None] = None, + send_platform_info: bool = True, + verify_ssl: SslConfig = None, + http_client: Optional[AsyncHttpClientProtocol] = None, + retry_config: RetryConfig = _DEFAULT_RETRY_CONFIG, + skip_language_check: bool = False, + _sleep_fn: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> None: + super().__init__(auth_key, server_url, send_platform_info) + + from . import http_client as _hc + + if _hc.max_network_retries is not None or ( + _hc.min_connection_timeout is not None + ): + retry_config = RetryConfig( + max_retries=( + _hc.max_network_retries + if _hc.max_network_retries is not None + else retry_config.max_retries + ), + min_connection_timeout=( + _hc.min_connection_timeout + if _hc.min_connection_timeout is not None + else retry_config.min_connection_timeout + ), + backoff_initial=retry_config.backoff_initial, + backoff_max=retry_config.backoff_max, + backoff_multiplier=retry_config.backoff_multiplier, + backoff_jitter=retry_config.backoff_jitter, + ) + + if http_client is not None and ( + proxy is not None or verify_ssl is not None + ): + raise ValueError( + "proxy and verify_ssl must not be set when " + "http_client is supplied; configure the " + "http_client directly instead." + ) + + if skip_language_check: + warnings.warn( + "skip_language_check is deprecated and has no effect.", + DeprecationWarning, + stacklevel=2, + ) + + if http_client is None: + from .aiohttp_client import AioHttpClient + + http_client = AioHttpClient( + proxy=proxy, + verify_ssl=verify_ssl, + ) + + self._http_client = http_client + self._closed = False + if hasattr(http_client, "http_library_info"): + self._http_library_info = http_client.http_library_info + if _hc.user_agent is not None: + self.set_user_agent(_hc.user_agent) + self._retry_config = retry_config + self._sleep_fn: Callable[[float], Awaitable[None]] = ( + _sleep_fn if _sleep_fn is not None else asyncio.sleep + ) + self.headers: Dict[str, str] = {} + + # ------------------------------------------------------------------ + # Context manager / lifecycle + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Close the underlying HTTP client and release resources.""" + if hasattr(self, "_http_client"): + await self._http_client.close() + self._closed = True + + def __del__(self) -> None: + # Mirror httpx/aiohttp: warn (but don't try to clean up) if the + # client was garbage-collected without an explicit close. We can't + # `await` here, and scheduling close on a possibly-dead loop is + # the bug we just fixed in AioHttpClient. The underlying aiohttp + # session has its own destructor warning; this one names our class + # so users know where to call close(). + if getattr(self, "_http_client", None) is not None and not getattr( + self, "_closed", True + ): + import warnings + + warnings.warn( + f"{type(self).__name__} was not closed; use " + "`async with` or `await client.close()`", + ResourceWarning, + stacklevel=2, + ) + + async def __aenter__(self) -> "DeepLClientAsync": + return self + + async def __aexit__(self, *args: Any) -> None: + await self.close() + + # ------------------------------------------------------------------ + # Internal: retry loop + # ------------------------------------------------------------------ + + async def _send_with_backoff(self, request: HttpRequest) -> HttpResponse: + """Send a request with exponential-backoff retry. + + Async version of :meth:`DeepLClient._send_with_backoff`. + """ + max_retries = self._retry_config.max_retries + min_timeout = self._retry_config.min_connection_timeout + + auth_headers = self._make_auth_headers() + merged_headers = {**auth_headers, **self.headers, **request.headers} + base_request = HttpRequest( + method=request.method, + url=request.url, + headers=merged_headers, + body=request.body, + multipart=request.multipart, + ) + + util.log_info( + "Request to DeepL API", + method=base_request.method, + url=base_request.url, + ) + if base_request.body: + try: + body_json = json_module.loads(base_request.body) + except Exception: + body_json = base_request.body.decode("utf-8", errors="replace") + util.log_debug("Request details", data={}, json=body_json) + elif base_request.multipart: + util.log_debug( + "Request details", + data=base_request.multipart.fields, + json=None, + ) + else: + util.log_debug("Request details", data={}, json=None) + + timer = BackoffTimer(self._retry_config) + while True: + request = HttpRequest( + method=base_request.method, + url=base_request.url, + headers=base_request.headers, + body=base_request.body, + multipart=base_request.multipart, + timeout=timer.get_timeout(min_timeout), + ) + response: Optional[HttpResponse] = None + exception: Optional[ConnectionException] = None + try: + response = await self._http_client.send(request) + except ConnectionException as e: + exception = e + + if exception is not None: + if ( + exception.should_retry + and timer.get_num_retries() < max_retries + ): + util.log_info( + f"Encountered a retryable-exception: {exception}" + ) + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + await self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + raise exception + + assert response is not None + util.log_info( + "DeepL API response", + url=request.url, + status_code=response.status_code, + ) + status = response.status_code + if ( + status == 429 or status >= 500 + ) and timer.get_num_retries() < max_retries: + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} for " + f"HTTP {status} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + await self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + + return response + + async def _send_streaming_with_backoff(self, request: HttpRequest) -> Any: + """Like _send_with_backoff but for streaming (document download).""" + max_retries = self._retry_config.max_retries + min_timeout = self._retry_config.min_connection_timeout + + auth_headers = self._make_auth_headers() + merged_headers = {**auth_headers, **self.headers, **request.headers} + base_request = HttpRequest( + method=request.method, + url=request.url, + headers=merged_headers, + body=request.body, + multipart=request.multipart, + ) + + util.log_info( + "Request to DeepL API", + method=base_request.method, + url=base_request.url, + ) + + timer = BackoffTimer(self._retry_config) + while True: + request = HttpRequest( + method=base_request.method, + url=base_request.url, + headers=base_request.headers, + body=base_request.body, + multipart=base_request.multipart, + timeout=timer.get_timeout(min_timeout), + ) + exception: Optional[ConnectionException] = None + streaming = None + try: + streaming = await self._http_client.send_streaming(request) + except ConnectionException as e: + exception = e + + if exception is not None: + if ( + exception.should_retry + and timer.get_num_retries() < max_retries + ): + util.log_info( + f"Encountered a retryable-exception: {exception}" + ) + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + await self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + raise exception + + assert streaming is not None + util.log_info( + "DeepL API response", + url=request.url, + status_code=streaming.status_code, + ) + status = streaming.status_code + if ( + status == 429 or status >= 500 + ) and timer.get_num_retries() < max_retries: + util.log_info( + f"Starting retry {timer.get_num_retries() + 1} for " + f"HTTP {status} after " + f"{timer.get_time_until_deadline():.2f}s" + ) + async for _ in streaming.aiter_content(65536): + pass + await streaming.close() + await self._sleep_fn(timer.get_time_until_deadline()) + timer.advance() + continue + + return streaming + + # ------------------------------------------------------------------ + # Public API: text translation + # ------------------------------------------------------------------ + + async def translate_text( + self, + text: Union[str, Iterable[str]], + *, + source_lang: Union[str, Language, None] = None, + target_lang: Union[str, Language], + context: Optional[str] = None, + split_sentences: Union[str, SplitSentences, None] = None, + preserve_formatting: Optional[bool] = None, + formality: Union[str, Formality, None] = None, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + tag_handling: Optional[str] = None, + tag_handling_version: Optional[str] = None, + outline_detection: Optional[bool] = None, + non_splitting_tags: Union[str, List[str], None] = None, + splitting_tags: Union[str, List[str], None] = None, + ignore_tags: Union[str, List[str], None] = None, + model_type: Union[str, ModelType, None] = None, + style_rule: Union[str, StyleRuleInfo, None] = None, + custom_instructions: Optional[List[str]] = None, + extra_body_parameters: Optional[dict] = None, + ) -> Union[TextResult, List[TextResult]]: + request, multi_input = _build_translate_text_request( + self._server_url, + text, + target_lang=target_lang, + source_lang=source_lang, + context=context, + split_sentences=split_sentences, + preserve_formatting=preserve_formatting, + formality=formality, + glossary=glossary, + tag_handling=tag_handling, + tag_handling_version=tag_handling_version, + outline_detection=outline_detection, + non_splitting_tags=non_splitting_tags, + splitting_tags=splitting_tags, + ignore_tags=ignore_tags, + model_type=model_type, + style_rule=style_rule, + custom_instructions=custom_instructions, + extra_body_parameters=extra_body_parameters, + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_translate_text_response(response, multi_input) + + async def translate_text_with_glossary( + self, + text: Union[str, Iterable[str]], + glossary: GlossaryInfo, + target_lang: Union[str, Language, None] = None, + **kwargs: Any, + ) -> Union[TextResult, List[TextResult]]: + if not isinstance(glossary, GlossaryInfo): + raise ValueError( + "This function expects the glossary parameter to be an " + "instance of GlossaryInfo." + ) + if target_lang is None: + target_lang = glossary.target_lang + if target_lang == "EN": + target_lang = "EN-GB" + return await self.translate_text( + text, + source_lang=glossary.source_lang, + target_lang=target_lang, + glossary=glossary, + **kwargs, + ) + + # ------------------------------------------------------------------ + # Public API: document translation + # ------------------------------------------------------------------ + + async def translate_document_from_filepath( + self, + input_path: Union[str, pathlib.PurePath], + output_path: Union[str, pathlib.PurePath], + *, + source_lang: Optional[str] = None, + target_lang: str, + formality: Union[str, Formality] = Formality.DEFAULT, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + timeout_s: Optional[int] = None, + extra_body_parameters: Optional[dict] = None, + ) -> DocumentStatus: + in_ext = pathlib.PurePath(input_path).suffix.lower() + out_ext = pathlib.PurePath(output_path).suffix.lower() + output_format = None if in_ext == out_ext else out_ext[1:] + + # File I/O is blocking; run open()/close() in a worker thread so + # the event loop is never stalled (especially relevant on slow + # disks or network mounts). + in_file = await asyncio.to_thread(open, input_path, "rb") + try: + out_file = await asyncio.to_thread(open, output_path, "wb") + try: + try: + return await self.translate_document( + in_file, + out_file, + target_lang=target_lang, + source_lang=source_lang, + formality=formality, + glossary=glossary, + output_format=output_format, + timeout_s=timeout_s, + extra_body_parameters=extra_body_parameters, + ) + except Exception: + await asyncio.to_thread(out_file.close) + await asyncio.to_thread(os.unlink, output_path) + raise + finally: + if not out_file.closed: + await asyncio.to_thread(out_file.close) + finally: + await asyncio.to_thread(in_file.close) + + async def translate_document( + self, + input_document: Union[TextIO, BinaryIO, Any], + output_document: Union[TextIO, BinaryIO, Any], + *, + source_lang: Optional[str] = None, + target_lang: str, + formality: Union[str, Formality] = Formality.DEFAULT, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + filename: Optional[str] = None, + output_format: Optional[str] = None, + timeout_s: Optional[int] = None, + extra_body_parameters: Optional[dict] = None, + ) -> DocumentStatus: + handle = await self.translate_document_upload( + input_document, + target_lang=target_lang, + source_lang=source_lang, + formality=formality, + glossary=glossary, + filename=filename, + output_format=output_format, + extra_body_parameters=extra_body_parameters, + ) + + try: + status = await self.translate_document_wait_until_done( + handle, timeout_s + ) + if status.ok: + await self.translate_document_download(handle, output_document) + except Exception as e: + raise DocumentTranslationException(str(e), handle) from e + + if not status.ok: + error_message = status.error_message or "unknown error" + raise DocumentTranslationException( + f"Error occurred while translating document: {error_message}", + handle, + ) + return status + + async def translate_document_upload( + self, + input_document: Union[TextIO, BinaryIO, str, bytes, Any], + *, + source_lang: Optional[str] = None, + target_lang: str, + formality: Union[str, Formality, None] = None, + glossary: Union[ + str, GlossaryInfo, MultilingualGlossaryInfo, None + ] = None, + filename: Optional[str] = None, + output_format: Optional[str] = None, + extra_body_parameters: Optional[dict] = None, + ) -> DocumentHandle: + request = _build_document_upload_request( + self._server_url, + input_document, + target_lang=target_lang, + source_lang=source_lang, + formality=formality, + glossary=glossary, + filename=filename, + output_format=output_format, + extra_body_parameters=extra_body_parameters, + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_document_upload_response(response) + + async def translate_document_get_status( + self, handle: DocumentHandle + ) -> DocumentStatus: + request = _build_document_status_request(self._server_url, handle) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_document_status_response(response, handle) + + async def translate_document_wait_until_done( + self, + handle: DocumentHandle, + timeout_s: Optional[int] = None, + ) -> DocumentStatus: + status = await self.translate_document_get_status(handle) + start_time_s = time.time() + while status.ok and not status.done: + if ( + timeout_s is not None + and time.time() - start_time_s > timeout_s + ): + raise DeepLException( + f"Manual timeout of {timeout_s}s exceeded for" + " document translation", + should_retry=False, + ) + secs = max( + ( + min(status.seconds_remaining, 5.0) + if status.seconds_remaining is not None + else 5.0 + ), + 1.0, + ) + util.log_info( + f"Rechecking document translation status " + f"after sleeping for {secs:.3f} seconds." + ) + await self._sleep_fn(secs) + status = await self.translate_document_get_status(handle) + return status + + async def translate_document_download( + self, + handle: DocumentHandle, + output_file: Union[TextIO, BinaryIO, Any, None] = None, + chunk_size: int = 1, + ) -> Optional[Any]: + """Download translated document. + + :param handle: DocumentHandle from translate_document_upload. + :param output_file: (Optional) File-like object to write content to. + If None, returns the raw streaming response; the caller MUST + release it via ``await streaming.close()`` or, preferably, + ``async with`` — otherwise the underlying TCP connection is + held until garbage collection:: + + download = await client.translate_document_download(handle) + async with download as resp: + async for chunk in resp.aiter_content(): + ... + :param chunk_size: Chunk size in bytes when writing to output_file. + :return: None if output_file provided, otherwise streaming response. + """ + request = _build_document_download_request(self._server_url, handle) + streaming = await self._send_streaming_with_backoff(request) + + if not (200 <= streaming.status_code < 400): + content = b"" + async for chunk in streaming.aiter_content(65536): + content += chunk + await streaming.close() + error_response = HttpResponse( + status_code=streaming.status_code, + headers=dict(streaming.headers), + content=content, + ) + self._raise_for_status(error_response, downloading_document=True) + + if output_file is not None: + async for chunk in streaming.aiter_content(chunk_size=chunk_size): + await asyncio.to_thread(output_file.write, chunk) + await streaming.close() + return None + return streaming + + # ------------------------------------------------------------------ + # Public API: usage and languages + # ------------------------------------------------------------------ + + async def get_usage(self) -> Usage: + request = _build_get_usage_request(self._server_url) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_usage_response(response) + + async def get_source_languages( + self, skip_cache: bool = False + ) -> List[Language]: + request = _build_get_source_languages_request(self._server_url) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_source_languages_response(response) + + async def get_target_languages( + self, skip_cache: bool = False + ) -> List[Language]: + request = _build_get_target_languages_request(self._server_url) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_target_languages_response(response) + + async def get_glossary_languages(self) -> List[GlossaryLanguagePair]: + request = _build_get_glossary_languages_request(self._server_url) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_glossary_languages_response(response) + + # ------------------------------------------------------------------ + # Public API: classic (v2) glossaries + # ------------------------------------------------------------------ + + async def create_glossary( + self, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + entries: Dict[str, str], + ) -> GlossaryInfo: + if not entries: + raise ValueError("glossary entries must not be empty") + return await self._create_glossary( + name, + source_lang, + target_lang, + "tsv", + util.convert_dict_to_tsv(entries), + ) + + async def create_glossary_from_csv( + self, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + csv_data: Union[TextIO, BinaryIO, str, bytes, Any], + ) -> GlossaryInfo: + entries = ( + csv_data if isinstance(csv_data, (str, bytes)) else csv_data.read() + ) + if not isinstance(entries, (bytes, str)): + raise ValueError("Entries of the glossary are invalid") + return await self._create_glossary( + name, source_lang, target_lang, "csv", entries + ) + + async def _create_glossary( + self, + name: str, + source_lang: Union[str, Language], + target_lang: Union[str, Language], + entries_format: str, + entries: Union[str, bytes], + ) -> GlossaryInfo: + request = _build_create_glossary_request( + self._server_url, + name, + source_lang, + target_lang, + entries_format, + entries, + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_create_glossary_response(response) + + async def get_glossary(self, glossary_id: str) -> GlossaryInfo: + request = _build_get_glossary_request(self._server_url, glossary_id) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_get_glossary_response(response) + + async def list_glossaries(self) -> List[GlossaryInfo]: + request = _build_list_glossaries_request(self._server_url) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_list_glossaries_response(response) + + async def get_glossary_entries( + self, glossary: Union[str, GlossaryInfo] + ) -> dict: + request = _build_get_glossary_entries_request( + self._server_url, glossary + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_get_glossary_entries_response(response) + + async def delete_glossary( + self, glossary: Union[str, GlossaryInfo] + ) -> None: + request = _build_delete_glossary_request(self._server_url, glossary) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + + # ------------------------------------------------------------------ + # Public API: multilingual (v3) glossaries + # ------------------------------------------------------------------ + + async def create_multilingual_glossary( + self, + name: str, + glossary_dicts: List[MultilingualGlossaryDictionaryEntries], + ) -> MultilingualGlossaryInfo: + if any(not d.entries for d in glossary_dicts): + raise ValueError("glossary entries must not be empty") + return await self._create_multilingual_glossary(name, glossary_dicts) + + async def create_multilingual_glossary_from_csv( + self, + name: str, + source_lang: str, + target_lang: str, + csv_data: Union[TextIO, BinaryIO, str, bytes, Any], + ) -> MultilingualGlossaryInfo: + entries = util.convert_csv_to_dict(csv_data) + dictionaries = [ + MultilingualGlossaryDictionaryEntries( + source_lang, target_lang, entries + ) + ] + return await self._create_multilingual_glossary(name, dictionaries) + + async def _create_multilingual_glossary( + self, + name: str, + glossary_dicts: List[MultilingualGlossaryDictionaryEntries], + ) -> MultilingualGlossaryInfo: + request = _build_create_multilingual_glossary_request( + self._server_url, name, glossary_dicts + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) + + async def update_multilingual_glossary_name( + self, + glossary: Union[str, MultilingualGlossaryInfo], + name: str, + ) -> MultilingualGlossaryInfo: + request = _build_update_multilingual_glossary_name_request( + self._server_url, glossary, name + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) + + async def update_multilingual_glossary_dictionary( + self, + glossary: Union[str, MultilingualGlossaryInfo], + glossary_dict: MultilingualGlossaryDictionaryEntries, + ) -> MultilingualGlossaryInfo: + if not glossary_dict or not glossary_dict.entries: + raise ValueError("glossary entries must not be empty") + request = _build_update_multilingual_glossary_dict_request( + self._server_url, glossary, [glossary_dict] + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) + + async def replace_multilingual_glossary_dictionary( + self, + glossary: Union[str, MultilingualGlossaryInfo], + glossary_dict: MultilingualGlossaryDictionaryEntries, + ) -> MultilingualGlossaryDictionaryInfo: + if not glossary_dict or not glossary_dict.entries: + raise ValueError("glossary entries must not be empty") + request = _build_replace_multilingual_glossary_dict_request( + self._server_url, + glossary, + glossary_dict.source_lang, + glossary_dict.target_lang, + glossary_dict.entries, + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_dict_response(response) + + async def get_multilingual_glossary( + self, glossary_id: str + ) -> MultilingualGlossaryInfo: + request = _build_get_multilingual_glossary_request( + self._server_url, glossary_id + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_response(response) + + async def list_multilingual_glossaries( + self, + ) -> List[MultilingualGlossaryInfo]: + request = _build_list_multilingual_glossaries_request(self._server_url) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_list_multilingual_glossaries_response(response) + + async def get_multilingual_glossary_entries( + self, + glossary: Union[str, MultilingualGlossaryInfo], + source_lang: str, + target_lang: str, + ) -> MultilingualGlossaryDictionaryEntriesResponse: + request = _build_get_multilingual_glossary_entries_request( + self._server_url, glossary, source_lang, target_lang + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + return _parse_multilingual_glossary_entries_response(response) + + async def delete_multilingual_glossary( + self, glossary: Union[str, MultilingualGlossaryInfo] + ) -> None: + request = _build_delete_multilingual_glossary_request( + self._server_url, glossary + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + + async def delete_multilingual_glossary_dictionary( + self, + glossary: Union[str, MultilingualGlossaryInfo], + dictionary: Optional[MultilingualGlossaryDictionaryInfo] = None, + source_lang: Optional[str] = None, + target_lang: Optional[str] = None, + ) -> None: + request = _build_delete_multilingual_glossary_dict_request( + self._server_url, glossary, source_lang, target_lang, dictionary + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response, glossary=True) + + # ------------------------------------------------------------------ + # Public API: Write (rephrase) + # ------------------------------------------------------------------ + + async def rephrase_text( + self, + text: Union[str, Iterable[str]], + *, + target_lang: Union[None, str, Language] = None, + style: Optional[str] = None, + tone: Optional[str] = None, + ) -> Union[WriteResult, List[WriteResult]]: + request, multi_input = _build_rephrase_text_request( + self._server_url, + text, + target_lang=target_lang, + style=style, + tone=tone, + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_rephrase_text_response(response, multi_input) + + # ------------------------------------------------------------------ + # Public API: style rules + # ------------------------------------------------------------------ + + async def get_all_style_rules( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + detailed: Optional[bool] = None, + ) -> List[StyleRuleInfo]: + request = _build_get_style_rules_request( + self._server_url, page, page_size, detailed + ) + response = await self._send_with_backoff(request) + self._raise_for_status(response) + return _parse_get_style_rules_response(response) diff --git a/deepl/http_client.py b/deepl/http_client.py index 9e6ae78..c003de2 100644 --- a/deepl/http_client.py +++ b/deepl/http_client.py @@ -2,264 +2,65 @@ # Use of this source code is governed by an MIT # license that can be found in the LICENSE file. -from . import version -from .exceptions import ConnectionException, DeepLException -import http -import platform -import random -import requests # type: ignore -import traceback -import time -from functools import lru_cache -from typing import Dict, Optional, Tuple, Union -from .util import log_info -from deepl import util - - +# Backward-compatibility shim. +# The public `HttpClient` class has been replaced by `RequestsClient`. +# `_BackoffTimer` has been replaced by `BackoffTimer` in `_backoff_timer.py`. +# Module-level globals (`max_network_retries`, `min_connection_timeout`) are +# preserved for old code but are deprecated. Set them to emit a +# DeprecationWarning; they are honoured by DeepLClient as long as they are +# set BEFORE the client is constructed. Use RetryConfig instead. + +import sys +import types +import warnings + +from .requests_client import RequestsClient as HttpClient # noqa: F401 +from ._backoff_timer import BackoffTimer as _BackoffTimer # noqa: F401 +from ._client_base import _generate_user_agent # noqa: F401 +from .exceptions import ConnectionException, DeepLException # noqa: F401 + +# Deprecated. Set before constructing a DeepLClient to override the +# User-Agent header. Use DeepLClient.set_user_agent() instead. user_agent = None -max_network_retries = 5 -min_connection_timeout = 10.0 - - -class _BackoffTimer: - """Implements exponential-backoff strategy. - This strategy is based on the GRPC Connection Backoff Protocol: - https://github.com/grpc/grpc/blob/master/doc/connection-backoff.md""" - - BACKOFF_INITIAL = 1.0 - BACKOFF_MAX = 120.0 - BACKOFF_JITTER = 0.23 - BACKOFF_MULTIPLIER = 1.6 - - def __init__(self): - self._num_retries = 0 - self._backoff = self.BACKOFF_INITIAL - self._deadline = time.time() + self._backoff - - def get_num_retries(self): - return self._num_retries - - def get_timeout(self): - return max(self.get_time_until_deadline(), min_connection_timeout) - - def get_time_until_deadline(self): - return max(self._deadline - time.time(), 0.0) - - def sleep_until_deadline(self): - time.sleep(self.get_time_until_deadline()) - - # Apply multiplier to current backoff time - self._backoff = min( - self._backoff * self.BACKOFF_MULTIPLIER, self.BACKOFF_MAX - ) - - # Get deadline by applying jitter as a proportion of backoff: - # if jitter is 0.1, then multiply backoff by random value in [0.9, 1.1] - self._deadline = time.time() + self._backoff * ( - 1 + self.BACKOFF_JITTER * random.uniform(-1, 1) - ) - self._num_retries += 1 - - -class HttpClient: - def __init__( - self, - proxy: Union[Dict, str, None] = None, - send_platform_info: bool = True, - verify_ssl: Union[bool, str, None] = None, - ): - self._session = requests.Session() - if proxy: - if isinstance(proxy, str): - proxy = {"http": proxy, "https": proxy} - if not isinstance(proxy, dict): - raise ValueError( - "proxy may be specified as a URL string or dictionary " - "containing URL strings for the http and https keys." - ) - self._session.proxies.update(proxy) - if verify_ssl is not None: - self._session.verify = verify_ssl - self._send_platform_info = send_platform_info - self._app_info_name: Optional[str] = None - self._app_info_version: Optional[str] = None - - def set_app_info(self, app_info_name: str, app_info_version: str): - self._app_info_name = app_info_name - self._app_info_version = app_info_version - return self - - def close(self): - self._session.close() - - def request_with_backoff( - self, - method: str, - url: str, - data: Optional[dict], - json: Optional[dict], - headers: dict, - stream: bool = False, - **kwargs, - ) -> Tuple[int, Union[str, requests.Response]]: - """Makes API request, retrying if necessary, and returns response. - - Return and exceptions are the same as function request().""" - backoff = _BackoffTimer() - request = self._prepare_request( - method, url, data, json, headers, **kwargs - ) - - while True: - response: Optional[Tuple[int, Union[str, requests.Response]]] - try: - response = self._internal_request( - request, stream=stream, timeout=backoff.get_timeout() - ) - exception = None - except Exception as e: - response = None - exception = e - - if not self._should_retry( - response, exception, backoff.get_num_retries() - ): - if response is not None: - return response - else: - raise exception # type: ignore[misc] - - if exception is not None: - log_info( - f"Encountered a retryable-exception: {str(exception)}" - ) - - log_info( - f"Starting retry {backoff.get_num_retries() + 1} for request " - f"{method} {url} after sleeping for " - f"{backoff.get_time_until_deadline():.2f} seconds." - ) - backoff.sleep_until_deadline() - - def request( - self, - method: str, - url: str, - data: Optional[dict], - json: Optional[dict], - headers: dict, - stream: bool = False, - **kwargs, - ) -> Tuple[int, Union[str, requests.Response]]: - """Makes API request and returns response content. - - Response is returned as HTTP status code and either content string (if - stream is False) or response (if stream is True). - - If no response is received will raise ConnectionException.""" - - request = self._prepare_request( - method, url, data, json, headers, **kwargs - ) - return self._internal_request(request, stream) - - def _internal_request( - self, - request: requests.PreparedRequest, - stream: bool, - timeout: float = min_connection_timeout, - **kwargs, - ) -> Tuple[int, Union[str, requests.Response]]: - try: - response = self._session.send( - request, stream=stream, timeout=timeout, **kwargs - ) - if stream: - return response.status_code, response - else: - try: - response.encoding = "UTF-8" - return response.status_code, response.text - finally: - response.close() - - except requests.exceptions.ConnectionError as e: - message = f"Connection failed: {e}" - raise ConnectionException(message, should_retry=True) from e - except requests.exceptions.Timeout as e: - message = f"Request timed out: {e}" - raise ConnectionException(message, should_retry=True) from e - except requests.exceptions.RequestException as e: - message = f"Request failed: {e}" - raise ConnectionException(message, should_retry=False) from e - except Exception as e: - message = f"Unexpected request failure: {e}" - raise ConnectionException(message, should_retry=False) from e - - def _should_retry(self, response, exception, num_retries): - if num_retries >= max_network_retries: - return False - - if response is None: - return exception.should_retry - - status_code, _ = response - # Retry on Too-Many-Requests error and internal errors - return status_code == http.HTTPStatus.TOO_MANY_REQUESTS or ( - status_code >= http.HTTPStatus.INTERNAL_SERVER_ERROR - ) - def _prepare_request( - self, - method: str, - url: str, - data: Optional[dict], - json: Optional[dict], - headers: dict, - **kwargs, - ) -> requests.PreparedRequest: - try: - headers.setdefault( - "User-Agent", - _generate_user_agent( - user_agent, - self._send_platform_info, - self._app_info_name, - self._app_info_version, - ), +# Deprecated. Default None means "not set by caller; use RetryConfig". +# Assign an int/float to override and receive a DeprecationWarning. +# Must be set before constructing a DeepLClient; values set afterwards +# are ignored. +max_network_retries = None +min_connection_timeout = None + +_DEPRECATED_GLOBALS = frozenset( + ("max_network_retries", "min_connection_timeout", "user_agent") +) + +_DEPRECATION_MESSAGES = { + "max_network_retries": ( + "deepl.http_client.max_network_retries is deprecated and will be " + "removed in a future version. Use RetryConfig instead." + ), + "min_connection_timeout": ( + "deepl.http_client.min_connection_timeout is deprecated and will be " + "removed in a future version. Use RetryConfig instead." + ), + "user_agent": ( + "deepl.http_client.user_agent is deprecated and will be removed in a " + "future version. Use DeepLClient.set_user_agent() instead." + ), +} + + +class _Module(types.ModuleType): + """Module subclass that intercepts assignment to deprecated globals.""" + + def __setattr__(self, name: str, value: object) -> None: + if name in _DEPRECATED_GLOBALS and value is not None: + warnings.warn( + _DEPRECATION_MESSAGES[name], + DeprecationWarning, + stacklevel=2, ) - return requests.Request( - method, url, data=data, headers=headers, json=json, **kwargs - ).prepare() - except Exception as e: - raise DeepLException( - f"Error occurred while preparing request: {e}" - ) from e + super().__setattr__(name, value) -@lru_cache(maxsize=4) -def _generate_user_agent( - user_agent_str: Optional[str], - send_platform_info: bool, - app_info_name: Optional[str], - app_info_version: Optional[str], -): - if user_agent_str: - library_info_str = user_agent_str - else: - library_info_str = f"deepl-python/{version.VERSION}" - if send_platform_info: - try: - library_info_str += ( - f" ({platform.platform()}) " - f"python/{platform.python_version()} " - f"requests/{requests.__version__}" - ) - except Exception: - util.log_info( - "Exception when querying platform information:\n" - + traceback.format_exc() - ) - if app_info_name and app_info_version: - library_info_str += f" {app_info_name}/{app_info_version}" - return library_info_str +sys.modules[__name__].__class__ = _Module diff --git a/deepl/ihttp_client.py b/deepl/ihttp_client.py new file mode 100644 index 0000000..d41613d --- /dev/null +++ b/deepl/ihttp_client.py @@ -0,0 +1,68 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +try: + from typing import Protocol, runtime_checkable +except ImportError: + from typing_extensions import Protocol, runtime_checkable # type: ignore + +from ._http_types import ( + AsyncStreamingHttpResponse, + HttpRequest, + HttpResponse, + StreamingHttpResponse, +) + + +@runtime_checkable +class HttpClientProtocol(Protocol): + """Protocol for synchronous HTTP clients. + + Custom implementations must: + - Make exactly one attempt per call (no internal retry). + - Translate all library-specific network errors to + :class:`~deepl.exceptions.ConnectionException` before raising. + + :meth:`send` is used for all endpoints except document download. + :meth:`send_streaming` is used for document download only. + """ + + @property + def http_library_info(self) -> str: + """Version string of the underlying HTTP library, e.g. + ``"requests/2.32.5"``. Included in the ``User-Agent`` header.""" + ... + + def send(self, request: HttpRequest) -> HttpResponse: ... + + def send_streaming( + self, request: HttpRequest + ) -> StreamingHttpResponse: ... + + def close(self) -> None: ... + + +@runtime_checkable +class AsyncHttpClientProtocol(Protocol): + """Protocol for asynchronous HTTP clients. + + Async counterpart to :class:`HttpClientProtocol`. Custom + implementations must satisfy the same contract: one attempt per + call, library-specific errors translated to + :class:`~deepl.exceptions.ConnectionException` before raising. + """ + + @property + def http_library_info(self) -> str: + """Version string of the underlying HTTP library, e.g. + ``"aiohttp/3.9.5"``. Included in the ``User-Agent`` header.""" + ... + + async def send(self, request: HttpRequest) -> HttpResponse: ... + + async def send_streaming( + self, request: HttpRequest + ) -> AsyncStreamingHttpResponse: ... + + async def close(self) -> None: ... diff --git a/deepl/requests_client.py b/deepl/requests_client.py new file mode 100644 index 0000000..18a9d82 --- /dev/null +++ b/deepl/requests_client.py @@ -0,0 +1,191 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import threading +from typing import Dict, Union + +import requests # type: ignore[import-untyped] +import requests.exceptions # type: ignore[import-untyped] + +from .exceptions import ConnectionException +from ._http_types import ( + HttpRequest, + HttpResponse, + SslConfig, + StreamingHttpResponse, +) + + +class RequestsClient: + """Synchronous HTTP client backed by :mod:`requests`. + + Uses a thread-local :class:`requests.Session` so that a single + ``DeepLClient`` instance can be shared across threads safely. + + Makes exactly one attempt per call — retry logic lives in + ``DeepLClient._send_with_backoff()``. + + All library-specific network errors are translated to + :class:`~deepl.exceptions.ConnectionException` before raising, so the + retry loop only needs to handle that one exception type. + + :param proxy: Proxy URL string or dict with ``"http"``/``"https"`` keys. + :param verify_ssl: SSL verification config (see :data:`~deepl.SslConfig`). + """ + + def __init__( + self, + proxy: Union[Dict, str, None] = None, + verify_ssl: SslConfig = None, + ) -> None: + self._proxy = proxy + self._verify_ssl = verify_ssl + self._local = threading.local() + + def _get_session(self) -> requests.Session: + """Return the thread-local session, creating it if needed.""" + if not hasattr(self._local, "session"): + session = requests.Session() + if self._proxy: + proxy = self._proxy + if isinstance(proxy, str): + proxy = {"http": proxy, "https": proxy} + if not isinstance(proxy, dict): + raise ValueError( + "proxy may be specified as a URL string or dictionary " + "containing URL strings for the http and https keys." + ) + session.proxies.update(proxy) + if self._verify_ssl is not None: + session.verify = self._verify_ssl + self._local.session = session + return self._local.session + + def send(self, request: HttpRequest) -> HttpResponse: + """Make one synchronous HTTP request; return a buffered response. + + :raises ConnectionException: On any network error. + """ + session = self._get_session() + timeout = request.timeout + try: + if request.multipart is not None: + mp = request.multipart + file_obj = mp.file_factory() + files = { + "file": (mp.file_name, file_obj, mp.file_content_type) + } + resp = session.request( + request.method, + request.url, + data=mp.fields, + files=files, + headers=request.headers, + timeout=timeout, + ) + elif request.body is not None: + resp = session.request( + request.method, + request.url, + data=request.body, + headers=request.headers, + timeout=timeout, + ) + else: + resp = session.request( + request.method, + request.url, + headers=request.headers, + timeout=timeout, + ) + content = resp.content + resp.close() + return HttpResponse( + status_code=resp.status_code, + headers=dict(resp.headers), + content=content, + ) + except requests.exceptions.ConnectionError as e: + raise ConnectionException( + f"Connection failed: {e}", should_retry=True + ) from e + except requests.exceptions.Timeout as e: + raise ConnectionException( + f"Request timed out: {e}", should_retry=True + ) from e + except requests.exceptions.RequestException as e: + raise ConnectionException( + f"Request failed: {e}", should_retry=False + ) from e + except Exception as e: + raise ConnectionException( + f"Unexpected request failure: {e}", should_retry=False + ) from e + + def send_streaming(self, request: HttpRequest) -> StreamingHttpResponse: + """Make one synchronous HTTP request; return a streaming response. + + The caller is responsible for iterating (and thereby consuming) the + response body via ``iter_content()``. + + :raises ConnectionException: On any network error before the response + headers are received. + """ + session = self._get_session() + timeout = request.timeout + try: + if request.body is not None: + resp = session.request( + request.method, + request.url, + data=request.body, + headers=request.headers, + timeout=timeout, + stream=True, + ) + else: + resp = session.request( + request.method, + request.url, + headers=request.headers, + timeout=timeout, + stream=True, + ) + # requests.Response satisfies StreamingHttpResponse Protocol: + # it has status_code, headers, and iter_content(). + return resp # type: ignore[return-value] + except requests.exceptions.ConnectionError as e: + raise ConnectionException( + f"Connection failed: {e}", should_retry=True + ) from e + except requests.exceptions.Timeout as e: + raise ConnectionException( + f"Request timed out: {e}", should_retry=True + ) from e + except requests.exceptions.RequestException as e: + raise ConnectionException( + f"Request failed: {e}", should_retry=False + ) from e + except Exception as e: + raise ConnectionException( + f"Unexpected request failure: {e}", should_retry=False + ) from e + + @property + def http_library_info(self) -> str: + return f"requests/{requests.__version__}" + + def close(self) -> None: + """Close the calling thread's session if one exists. + + Note: only cleans up the session belonging to the thread that calls + this method. Sessions created on other threads are not closed. This + is an inherent limitation of thread-local storage; in practice it + means the ``DeepLClient.__del__`` / ``__exit__`` cleanup may leave + open connections on worker threads. Call ``close()`` from each + thread that used the client if complete cleanup is required. + """ + if hasattr(self._local, "session"): + self._local.session.close() + del self._local.session diff --git a/deepl/retry_config.py b/deepl/retry_config.py new file mode 100644 index 0000000..c74deec --- /dev/null +++ b/deepl/retry_config.py @@ -0,0 +1,32 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RetryConfig: + """Immutable configuration for HTTP retry and backoff behaviour. + + Passed to DeepLClient / DeepLClientAsync. The same config applies to + all requests; construct a second client if you need different settings + for a subset of calls. + + :param max_retries: Maximum number of retry attempts (default 5). + :param min_connection_timeout: Minimum timeout in seconds per attempt + (default 10.0). Passed to the underlying HTTP client. + :param backoff_initial: Initial backoff duration in seconds (default 1.0). + :param backoff_max: Maximum backoff duration in seconds (default 120.0). + :param backoff_multiplier: Multiplier applied to backoff after each retry + (default 1.6). + :param backoff_jitter: Jitter as a proportion of backoff (default 0.23). + The actual sleep duration is backoff * (1 ± jitter). + """ + + max_retries: int = 5 + min_connection_timeout: float = 10.0 + backoff_initial: float = 1.0 + backoff_max: float = 120.0 + backoff_multiplier: float = 1.6 + backoff_jitter: float = 0.23 diff --git a/deepl/translator.py b/deepl/translator.py index 1ebbc9e..06723a0 100644 --- a/deepl/translator.py +++ b/deepl/translator.py @@ -2,1202 +2,20 @@ # Use of this source code is governed by an MIT # license that can be found in the LICENSE file. -from deepl.api_data import ( +# Backward-compatibility shim. +# `Translator` was the original public class name; `DeepLClient` is preferred. +from .deepl_client import DeepLClient as Translator # noqa: F401 + +# Re-export api_data types that were historically importable from this module. +from .api_data import ( # noqa: F401 DocumentHandle, DocumentStatus, Formality, - GlossaryLanguagePair, GlossaryInfo, - MultilingualGlossaryInfo, - ModelType, Language, + ModelType, SplitSentences, - StyleRuleInfo, TextResult, TranslationMemoryInfo, Usage, ) -from . import http_client, util -from .exceptions import ( - DocumentNotReadyException, - GlossaryNotFoundException, - QuotaExceededException, - TooManyRequestsException, - DeepLException, - AuthorizationException, - DocumentTranslationException, -) -import http -import http.client -import json as json_module -import os -import pathlib -import requests # type: ignore -import time -from typing import ( - Any, - BinaryIO, - Dict, - Iterable, - List, - Optional, - TextIO, - Tuple, - Union, -) -import urllib.parse - - -class Translator: - """Wrapper for the DeepL API for language translation. - - You must create an instance of Translator to use the DeepL API. - - :param auth_key: Authentication key as found in your DeepL API account. - :param server_url: (Optional) Base URL of DeepL API, can be overridden e.g. - for testing purposes. - :param proxy: (Optional) Proxy server URL string or dictionary containing - URL strings for the 'http' and 'https' keys. This is passed to the - underlying requests session, see the requests proxy documentation for - more information. - :param send_platform_info: (Optional) boolean that indicates if the client - library can send basic platform info (python version, OS, http library - version) to the DeepL API. True = send info, False = only send client - library version - :param verify_ssl: (Optional) Controls how requests verifies SSL - certificates. This is passed to the underlying requests session, see - the requests verify documentation for more information. - :param skip_language_check: Deprecated, and now has no effect as the - corresponding internal functionality has been removed. This parameter - will be removed in a future version. - - All functions may raise DeepLException or a subclass if a connection error - occurs. - """ - - _DEEPL_SERVER_URL = "https://api.deepl.com" - _DEEPL_SERVER_URL_FREE = "https://api-free.deepl.com" - - # HTTP status code used by DeepL API to indicate the character limit for - # this billing period has been reached. - _HTTP_STATUS_QUOTA_EXCEEDED = 456 - - def __init__( - self, - auth_key: str, - *, - server_url: Optional[str] = None, - proxy: Union[Dict, str, None] = None, - send_platform_info: bool = True, - verify_ssl: Union[bool, str, None] = None, - skip_language_check: bool = False, - ): - if not auth_key: - raise ValueError("auth_key must not be empty") - - if server_url is None: - server_url = ( - self._DEEPL_SERVER_URL_FREE - if util.auth_key_is_free_account(auth_key) - else self._DEEPL_SERVER_URL - ) - - self._server_url = server_url - self._client = http_client.HttpClient( - proxy, send_platform_info, verify_ssl - ) - self.headers = {"Authorization": f"DeepL-Auth-Key {auth_key}"} - - def __del__(self): - self.close() - - def _api_call( - self, - url: str, - *, - method: str = "POST", - data: Optional[dict] = None, - json: Optional[dict] = None, - stream: bool = False, - headers: Optional[dict] = None, - **kwargs, - ) -> Tuple[int, Union[str, requests.Response], Any]: - """ - Makes a request to the API, and returns response as status code, - content and JSON object. - """ - if data is not None and json is not None: - raise ValueError("cannot accept both json and data") - - if data is None: - data = {} - url = urllib.parse.urljoin(self._server_url, url) - - util.log_info("Request to DeepL API", method=method, url=url) - util.log_debug("Request details", data=data, json=json) - - if headers is None: - headers = dict() - headers.update( - {k: v for k, v in self.headers.items() if k not in headers} - ) - - status_code, content = self._client.request_with_backoff( - method, - url, - data=data, - json=json, - stream=stream, - headers=headers, - **kwargs, - ) - - json = None - if isinstance(content, str): - content_str = content - try: - json = json_module.loads(content) - except json_module.JSONDecodeError: - pass - else: - content_str = content.text - - util.log_info("DeepL API response", url=url, status_code=status_code) - util.log_debug("Response details", content=content_str) - - return status_code, content, json - - def _raise_for_status( - self, - status_code: int, - content: Union[str, requests.Response], - json: Any, - glossary: bool = False, - downloading_document: bool = False, - ): - message = "" - if json is not None and isinstance(json, dict) and "message" in json: - message += ", message: " + json["message"] - if json is not None and isinstance(json, dict) and "detail" in json: - message += ", detail: " + json["detail"] - - if 200 <= status_code < 400: - return - elif status_code == http.HTTPStatus.FORBIDDEN: - raise AuthorizationException( - f"Authorization failure, check auth_key{message}", - http_status_code=status_code, - ) - elif status_code == self._HTTP_STATUS_QUOTA_EXCEEDED: - raise QuotaExceededException( - f"Quota for this billing period has been exceeded{message}", - http_status_code=status_code, - ) - elif status_code == http.HTTPStatus.NOT_FOUND: - if glossary: - raise GlossaryNotFoundException( - f"Glossary not found{message}", - http_status_code=status_code, - ) - raise DeepLException( - f"Not found{message}", - http_status_code=status_code, - ) - elif status_code == http.HTTPStatus.BAD_REQUEST: - raise DeepLException( - f"Bad request{message}", http_status_code=status_code - ) - elif status_code == http.HTTPStatus.TOO_MANY_REQUESTS: - raise TooManyRequestsException( - "Too many requests, DeepL servers are currently experiencing " - f"high load{message}", - should_retry=True, - http_status_code=status_code, - ) - elif status_code == http.HTTPStatus.SERVICE_UNAVAILABLE: - if downloading_document: - raise DocumentNotReadyException( - f"Document not ready{message}", - should_retry=True, - http_status_code=status_code, - ) - else: - raise DeepLException( - f"Service unavailable{message}", - should_retry=True, - http_status_code=status_code, - ) - else: - status_name = ( - http.client.responses[status_code] - if status_code in http.client.responses - else "Unknown" - ) - content_str = content if isinstance(content, str) else content.text - raise DeepLException( - f"Unexpected status code: {status_code} {status_name}, " - f"content: {content_str}.", - should_retry=False, - http_status_code=status_code, - ) - - def _check_valid_languages( - self, source_lang: Optional[str], target_lang: str - ): - """Internal function to check given languages are valid.""" - if target_lang == "EN": - raise DeepLException( - 'target_lang="EN" is deprecated, please use "EN-GB" or "EN-US"' - "instead." - ) - elif target_lang == "PT": - raise DeepLException( - 'target_lang="PT" is deprecated, please use "PT-PT" or "PT-BR"' - "instead." - ) - - def _check_language_and_formality( - self, - source_lang: Union[str, Language, None], - target_lang: Union[str, Language], - formality: Union[str, Formality, None], - glossary: Union[ - str, GlossaryInfo, MultilingualGlossaryInfo, None - ] = None, - style_rule: Union[str, StyleRuleInfo, None] = None, - translation_memory: Union[str, TranslationMemoryInfo, None] = None, - translation_memory_threshold: Optional[int] = None, - ) -> dict: - # target_lang and source_lang are case insensitive - target_lang = str(target_lang).upper() - if source_lang is not None: - source_lang = str(source_lang).upper() - - if glossary is not None and source_lang is None: - raise ValueError("source_lang is required if using a glossary") - - if isinstance(glossary, GlossaryInfo): - if ( - Language.remove_regional_variant(target_lang) - != glossary.target_lang - or source_lang != glossary.source_lang - ): - raise ValueError( - "source_lang and target_lang must match glossary" - ) - - if isinstance(glossary, MultilingualGlossaryInfo): - target_lang_code = Language.remove_regional_variant(target_lang) - if not any( - glossary_dict.target_lang == target_lang_code - and glossary_dict.source_lang == source_lang - for glossary_dict in glossary.dictionaries - ): - raise ValueError( - "must have a glossary with a dictionary for the given " - "source_lang and target_lang" - ) - - if isinstance(style_rule, StyleRuleInfo): - if ( - Language.remove_regional_variant(target_lang) - != style_rule.language.upper() - ): - raise ValueError("target_lang must match style rule language") - - self._check_valid_languages(source_lang, target_lang) - - request_data: dict = {"target_lang": target_lang} - if source_lang is not None: - request_data["source_lang"] = source_lang - if formality is not None: - request_data["formality"] = str(formality).lower() - if isinstance(glossary, GlossaryInfo) or isinstance( - glossary, MultilingualGlossaryInfo - ): - request_data["glossary_id"] = glossary.glossary_id - elif glossary is not None: - request_data["glossary_id"] = glossary - if isinstance(style_rule, StyleRuleInfo): - request_data["style_id"] = style_rule.style_id - elif style_rule is not None: - request_data["style_id"] = style_rule - if isinstance(translation_memory, TranslationMemoryInfo): - request_data["translation_memory_id"] = ( - translation_memory.translation_memory_id - ) - elif translation_memory is not None: - request_data["translation_memory_id"] = translation_memory - if translation_memory_threshold is not None: - if translation_memory is None: - raise ValueError( - "translation_memory_threshold requires" - " translation_memory" - ) - if not (0 <= translation_memory_threshold <= 100): - raise ValueError( - "translation_memory_threshold must be between 0 and 100" - ) - request_data["translation_memory_threshold"] = ( - translation_memory_threshold - ) - return request_data - - def _create_glossary( - self, - name: str, - source_lang: Union[str, Language], - target_lang: Union[str, Language], - entries_format: str, - entries: Union[str, bytes], - ) -> GlossaryInfo: - # glossaries are only supported for base language types - source_lang = Language.remove_regional_variant(source_lang) - target_lang = Language.remove_regional_variant(target_lang) - - if not name: - raise ValueError("glossary name must not be empty") - - request_data = { - "name": name, - "source_lang": source_lang, - "target_lang": target_lang, - "entries_format": entries_format, - "entries": entries, - } - - status, content, json = self._api_call( - "v2/glossaries", json=request_data - ) - self._raise_for_status(status, content, json, glossary=True) - return GlossaryInfo.from_json(json) - - def close(self): - if hasattr(self, "_client"): - self._client.close() - - def set_app_info(self, app_info_name: str, app_info_version: str): - self._client.set_app_info(app_info_name, app_info_version) - return self - - @property - def server_url(self): - return self._server_url - - def translate_text( - self, - text: Union[str, Iterable[str]], - *, - source_lang: Union[str, Language, None] = None, - target_lang: Union[str, Language], - context: Optional[str] = None, - split_sentences: Union[str, SplitSentences, None] = None, - preserve_formatting: Optional[bool] = None, - formality: Union[str, Formality, None] = None, - glossary: Union[ - str, GlossaryInfo, MultilingualGlossaryInfo, None - ] = None, - tag_handling: Optional[str] = None, - tag_handling_version: Optional[str] = None, - outline_detection: Optional[bool] = None, - non_splitting_tags: Union[str, List[str], None] = None, - splitting_tags: Union[str, List[str], None] = None, - ignore_tags: Union[str, List[str], None] = None, - model_type: Union[str, ModelType, None] = None, - style_rule: Union[str, StyleRuleInfo, None] = None, - translation_memory: Union[str, TranslationMemoryInfo, None] = None, - translation_memory_threshold: Optional[int] = None, - custom_instructions: Optional[List[str]] = None, - extra_body_parameters: Optional[dict] = None, - ) -> Union[TextResult, List[TextResult]]: - """Translate text(s) into the target language. - - :param text: Text to translate. - :type text: UTF-8 :class:`str`; string sequence (list, tuple, iterator, - generator) - :param source_lang: (Optional) Language code of input text, for example - "DE", "EN", "FR". If omitted, DeepL will auto-detect the input - language. If a glossary is used, source_lang must be specified. - :param target_lang: language code to translate text into, for example - "DE", "EN-US", "FR". - :param context: (Optional) Additional contextual text to influence - translations, that is not translated itself. Characters in the - `context` parameter are not counted toward billing. See the API - documentation for more information and example usage. - :param split_sentences: (Optional) Controls how the translation engine - should split input into sentences before translation, see - :class:`SplitSentences`. - :param preserve_formatting: (Optional) Set to True to prevent the - translation engine from correcting some formatting aspects, and - instead leave the formatting unchanged. - :param formality: (Optional) Desired formality for translation, as - Formality enum, "less", "more", "prefer_less", "prefer_more", or - "default". - :param glossary: (Optional) glossary or glossary ID to use for - translation. Must match specified source_lang and target_lang. - :param style_rule: (Optional) style rule or style rule ID to use for - translation. - :param translation_memory: (Optional) translation memory or translation - memory ID to use for translation. - :param translation_memory_threshold: (Optional) minimum matching - percentage for fuzzy matches from the translation memory (0-100). - Recommended minimum is 75%. - :param tag_handling: (Optional) Type of tags to parse before - translation, only "xml" and "html" are currently available. - :param tag_handling_version: (Optional) Version of tag handling - algorithm to use, "v1" or "v2". - :param outline_detection: (Optional) Set to False to disable automatic - tag detection. - :param non_splitting_tags: (Optional) XML tags that should not split a - sentence. - :type non_splitting_tags: List of XML tags or comma-separated-list of - tags. - :param splitting_tags: (Optional) XML tags that should split a - sentence. - :type splitting_tags: List of XML tags or comma-separated-list of tags. - :param ignore_tags: (Optional) XML tags containing text that should not - be translated. - :type ignore_tags: List of XML tags or comma-separated-list of tags. - :param model_type: (Optional) Controls whether the translation engine - should use a potentially slower model to achieve higher quality. - :param custom_instructions: (Optional) List of custom instructions to - guide the translation. Maximum of 10 instructions, each with a - maximum length of 300 characters. - :param extra_body_parameters: (Optional) Additional key/value pairs to - include in the JSON request body sent to the API. If provided, - keys in this dict will be added to the request body. Existing - keys set by the client will not be overwritten by entries in - extra_body_parameters. - :return: List of TextResult objects containing results, unless input - text was one string, then a single TextResult object is returned. - """ - if isinstance(text, str): - if len(text) == 0: - raise ValueError("text must not be empty") - text = [text] - multi_input = False - elif hasattr(text, "__iter__"): - multi_input = True - text = list(text) - else: - raise TypeError( - "text parameter must be a string or an iterable of strings" - ) - - request_data = self._check_language_and_formality( - source_lang, - target_lang, - formality, - glossary, - style_rule, - translation_memory, - translation_memory_threshold, - ) - request_data["text"] = text - - # Always send show_billed_characters=true, remove when the API default - # is changed to true - request_data["show_billed_characters"] = True - - if context is not None: - request_data["context"] = context - if split_sentences is not None: - request_data["split_sentences"] = str(split_sentences) - if preserve_formatting is not None: - request_data["preserve_formatting"] = bool(preserve_formatting) - if tag_handling is not None: - request_data["tag_handling"] = tag_handling - if tag_handling_version is not None: - request_data["tag_handling_version"] = tag_handling_version - if outline_detection is not None: - request_data["outline_detection"] = bool(outline_detection) - if model_type is not None: - request_data["model_type"] = str(model_type) - - def join_tags(tag_argument: Union[str, Iterable[str]]) -> List[str]: - if isinstance(tag_argument, str): - tag_argument = [tag_argument] - return [ - tag - for arg_string in tag_argument - for tag in arg_string.split(",") - ] - - if non_splitting_tags is not None: - request_data["non_splitting_tags"] = join_tags(non_splitting_tags) - if splitting_tags is not None: - request_data["splitting_tags"] = join_tags(splitting_tags) - if ignore_tags is not None: - request_data["ignore_tags"] = join_tags(ignore_tags) - if custom_instructions is not None: - request_data["custom_instructions"] = custom_instructions - - # Do not overwrite keys that were explicitly set by this method. - if extra_body_parameters: - for k, v in extra_body_parameters.items(): - request_data[k] = v - - status, content, json = self._api_call( - "v2/translate", json=request_data - ) - - self._raise_for_status(status, content, json) - - translations = ( - json.get("translations", []) - if (json and isinstance(json, dict)) - else [] - ) - output = [] - for translation in translations: - text = translation.get("text", "") if translation else "" - detected_source_language = ( - translation.get("detected_source_language", "") - if translation - else "" - ) - billed_characters = int(translation.get("billed_characters")) - model_type_used = translation.get("model_type_used") - output.append( - TextResult( - text, - detected_source_language, - billed_characters, - model_type_used, - ) - ) - - return output if multi_input else output[0] - - def translate_text_with_glossary( - self, - text: Union[str, Iterable[str]], - glossary: GlossaryInfo, - target_lang: Union[str, Language, None] = None, - **kwargs, - ) -> Union[TextResult, List[TextResult]]: - """Translate text(s) using given glossary. The source and target - languages are assumed to match the glossary languages. - - Note that if the glossary target language is English (EN), the text - will be translated into British English (EN-GB). To instead translate - into American English specify target_lang="EN-US". - - :param text: Text to translate. - :type text: UTF-8 :class:`str`; string sequence (list, tuple, iterator, - generator) - :param glossary: glossary to use for translation. - :type glossary: :class:`GlossaryInfo`. - :param target_lang: override target language of glossary. - :return: List of TextResult objects containing results, unless input - text was one string, then a single TextResult object is returned. - """ - - if not isinstance(glossary, GlossaryInfo): - msg = ( - "This function expects the glossary parameter to be an " - "instance of GlossaryInfo. Use get_glossary() to obtain a " - "GlossaryInfo using the glossary ID of an existing " - "glossary. Alternatively, use translate_text() and " - "specify the glossary ID using the glossary parameter. " - ) - raise ValueError(msg) - - if target_lang is None: - target_lang = glossary.target_lang - if target_lang == "EN": - target_lang = "EN-GB" - - return self.translate_text( - text, - source_lang=glossary.source_lang, - target_lang=target_lang, - glossary=glossary, - **kwargs, - ) - - def translate_document_from_filepath( - self, - input_path: Union[str, pathlib.PurePath], - output_path: Union[str, pathlib.PurePath], - *, - source_lang: Optional[str] = None, - target_lang: str, - formality: Union[str, Formality] = Formality.DEFAULT, - glossary: Union[ - str, GlossaryInfo, MultilingualGlossaryInfo, None - ] = None, - timeout_s: Optional[int] = None, - extra_body_parameters: Optional[dict] = None, - ) -> DocumentStatus: - """Upload document at given input path, translate it into the target - language, and download result to given output path. - - :param input_path: Path to document to be translated. - :param output_path: Path to store translated document. - :param source_lang: (Optional) Language code of input document, for - example "DE", "EN", "FR". If omitted, DeepL will auto-detect the - input language. - :param target_lang: Language code to translate document into, for - example "DE", "EN-US", "FR". - :param formality: (Optional) Desired formality for translation, as - Formality enum, "less", "more", "prefer_less", or "prefer_more". - :param glossary: (Optional) glossary or glossary ID to use for - translation. Must match specified source_lang and target_lang. - :param timeout_s: (beta) (Optional) Maximum time to wait before - the call raises an error. Note that this is not accurate to the - second, but only polls every 5 seconds. - :param extra_body_parameters: (Optional) Additional key/value pairs to - include in the JSON request body sent to the API. If provided, - keys in this dict will be added to the request body. Existing - keys set by the client will not be overwritten by entries in - extra_body_parameters. - :return: DocumentStatus when document translation completed, this - allows the number of billed characters to be queried. - - :raises DocumentTranslationException: If an error occurs during - translation. The exception includes information about the document - request. - """ - # Determine output_format from output path - in_ext = pathlib.PurePath(input_path).suffix.lower() - out_ext = pathlib.PurePath(output_path).suffix.lower() - output_format = None if in_ext == out_ext else out_ext[1:] - - with open(input_path, "rb") as in_file: - with open(output_path, "wb") as out_file: - try: - return self.translate_document( - in_file, - out_file, - target_lang=target_lang, - source_lang=source_lang, - formality=formality, - glossary=glossary, - output_format=output_format, - timeout_s=timeout_s, - extra_body_parameters=extra_body_parameters, - ) - except Exception as e: - out_file.close() - os.unlink(output_path) - raise e - - def translate_document( - self, - input_document: Union[TextIO, BinaryIO, Any], - output_document: Union[TextIO, BinaryIO, Any], - *, - source_lang: Optional[str] = None, - target_lang: str, - formality: Union[str, Formality] = Formality.DEFAULT, - glossary: Union[ - str, GlossaryInfo, MultilingualGlossaryInfo, None - ] = None, - filename: Optional[str] = None, - output_format: Optional[str] = None, - timeout_s: Optional[int] = None, - extra_body_parameters: Optional[dict] = None, - ) -> DocumentStatus: - """Upload document, translate it into the target language, and download - result. - - :param input_document: Document to translate as a file-like object. It - is recommended to open files in binary mode. - :param output_document: File-like object to receive translated - document. - :param source_lang: (Optional) Language code of input document, for - example "DE", "EN", "FR". If omitted, DeepL will auto-detect the - input language. - :param target_lang: Language code to translate document into, for - example "DE", "EN-US", "FR". - :param formality: (Optional) Desired formality for translation, as - Formality enum, "less", "more", "prefer_less", or "prefer_more". - :param glossary: (Optional) glossary or glossary ID to use for - translation. Must match specified source_lang and target_lang. - :param filename: (Optional) Filename including extension, only required - if uploading string or bytes containing file content. - :param output_format: (Optional) Desired output file extension, if - it differs from the input file format. - :param timeout_s: (beta) (Optional) Maximum time to wait before - the call raises an error. Note that this is not accurate to the - second, but only polls every 5 seconds. - :param extra_body_parameters: (Optional) Additional key/value pairs to - include in the JSON request body sent to the API. If provided, - keys in this dict will be added to the request body. Existing - keys set by the client will not be overwritten by entries in - extra_body_parameters. - :return: DocumentStatus when document translation completed, this - allows the number of billed characters to be queried. - - :raises DocumentTranslationException: If an error occurs during - translation, the exception includes the document handle. - """ - - handle = self.translate_document_upload( - input_document, - target_lang=target_lang, - source_lang=source_lang, - formality=formality, - glossary=glossary, - filename=filename, - output_format=output_format, - extra_body_parameters=extra_body_parameters, - ) - - try: - status = self.translate_document_wait_until_done(handle, timeout_s) - if status.ok: - self.translate_document_download(handle, output_document) - except Exception as e: - raise DocumentTranslationException(str(e), handle) from e - - if not status.ok: - error_message = status.error_message or "unknown error" - raise DocumentTranslationException( - f"Error occurred while translating document: {error_message}", - handle, - ) - return status - - def translate_document_upload( - self, - input_document: Union[TextIO, BinaryIO, str, bytes, Any], - *, - source_lang: Optional[str] = None, - target_lang: str, - formality: Union[str, Formality, None] = None, - glossary: Union[ - str, GlossaryInfo, MultilingualGlossaryInfo, None - ] = None, - filename: Optional[str] = None, - output_format: Optional[str] = None, - extra_body_parameters: Optional[dict] = None, - ) -> DocumentHandle: - """Upload document to be translated and return handle associated with - request. - - :param input_document: Document to translate as a file-like object, or - string or bytes containing file content. - :param source_lang: (Optional) Language code of input document, for - example "DE", "EN", "FR". If omitted, DeepL will auto-detect the - input language. - :param target_lang: Language code to translate document into, for - example "DE", "EN-US", "FR". - :param formality: (Optional) Desired formality for translation, as - Formality enum, "less", "more", "prefer_less", or "prefer_more". - :param glossary: (Optional) glossary or glossary ID to use for - translation. Must match specified source_lang and target_lang. - :param filename: (Optional) Filename including extension, only required - if uploading string or bytes containing file content. - :param output_format: (Optional) Desired output file extension, if - it differs from the input file format. - :param extra_body_parameters: (Optional) Additional key/value pairs to - include in the JSON request body sent to the API. If provided, - keys in this dict will be added to the request body. Existing - keys set by the client will not be overwritten by entries in - extra_body_parameters. - :return: DocumentHandle with ID and key identifying document. - """ - - request_data = self._check_language_and_formality( - source_lang, target_lang, formality, glossary - ) - if output_format: - request_data["output_format"] = output_format - - files: Dict[str, Any] = {} - if isinstance(input_document, (str, bytes)): - if filename is None: - raise ValueError( - "filename is required if uploading file content as string " - "or bytes" - ) - files = {"file": (filename, input_document)} - else: - files = {"file": input_document} - if extra_body_parameters: - for k, v in extra_body_parameters.items(): - request_data[k] = v - - status, content, json = self._api_call( - "v2/document", data=request_data, files=files - ) - self._raise_for_status(status, content, json) - - if not json: - json = {} - return DocumentHandle( - json.get("document_id", ""), json.get("document_key", "") - ) - - def translate_document_get_status( - self, handle: DocumentHandle - ) -> DocumentStatus: - """Gets the status of the document translation request associated with - given handle. - - :param handle: DocumentHandle to the request to check. - :return: DocumentStatus containing the request status. - - :raises DocumentTranslationException: If an error occurs during - querying the document, the exception includes the document handle. - """ - - data = {"document_key": handle.document_key} - url = f"v2/document/{handle.document_id}" - - status_code, content, json = self._api_call(url, json=data) - - self._raise_for_status(status_code, content, json) - - status = ( - json.get("status", None) - if (json and isinstance(json, dict)) - else None - ) - if not status: - raise DocumentTranslationException( - "Querying document status gave an empty response", handle - ) - seconds_remaining = ( - json.get("seconds_remaining", None) - if (json and isinstance(json, dict)) - else None - ) - billed_characters = ( - json.get("billed_characters", None) - if (json and isinstance(json, dict)) - else None - ) - error_message = ( - json.get("error_message", None) - if (json and isinstance(json, dict)) - else None - ) - return DocumentStatus( - status, seconds_remaining, billed_characters, error_message - ) - - def translate_document_wait_until_done( - self, - handle: DocumentHandle, - timeout_s: Optional[int] = None, - ) -> DocumentStatus: - """ - Continually polls the status of the document translation associated - with the given handle, sleeping in between requests, and returns the - final status when the translation completes (whether successful or - not). - :param handle: DocumentHandle to the document translation to wait on. - :param timeout_s: (beta) (Optional) Maximum time to wait before - the call raises an error. Note that this is not accurate to the - second, but only polls every 5 seconds. - :return: DocumentStatus containing the status when completed. - """ - status = self.translate_document_get_status(handle) - start_time_s = time.time() - while status.ok and not status.done: - if ( - timeout_s is not None - and time.time() - start_time_s > timeout_s - ): - raise DeepLException( - f"Manual timeout of {timeout_s}s exceeded for" - + " document translation", - should_retry=False, - ) - else: - secs = ( - 5.0 # seconds_remaining is currently unreliable, so just - ) - # poll equidistantly - util.log_info( - f"Rechecking document translation status " - f"after sleeping for {secs:.3f} seconds." - ) - time.sleep(secs) - status = self.translate_document_get_status(handle) - return status - - def translate_document_download( - self, - handle: DocumentHandle, - output_file: Union[TextIO, BinaryIO, Any, None] = None, - chunk_size: int = 1, - ) -> Optional[requests.Response]: - """Downloads the translated document for the request associated with - given handle and returns a response object for streaming the data. Call - iter_content() on the response object to read streamed file data. - Alternatively, a file-like object may be given as output_file where the - complete file will be downloaded and written to. - - :param handle: DocumentHandle associated with request. - :param output_file: (Optional) File-like object to store downloaded - document. If not provided, use iter_content() on the returned - response object to read streamed file data. - :param chunk_size: (Optional) Size of chunk in bytes for streaming. - Only used if output_file is specified. - :return: None if output_file is specified, otherwise the - requests.Response will be returned. - """ - - data = {"document_key": handle.document_key} - url = f"v2/document/{handle.document_id}/result" - - status_code, response, json = self._api_call( - url, json=data, stream=True - ) - # TODO: once we drop py3.6 support, replace this with @overload - # annotations in `_api_call` and chained private functions. - # See for example https://stackoverflow.com/a/74070166/4926599 - # In addition, drop the type: ignore annotation on the - # `import requests` / `from requests` - assert isinstance(response, requests.Response) - - self._raise_for_status( - status_code, "", json, downloading_document=True - ) - - if output_file: - chunks = response.iter_content(chunk_size=chunk_size) - for chunk in chunks: - output_file.write(chunk) - return None - else: - return response - - def get_source_languages(self, skip_cache: bool = False) -> List[Language]: - """Request the list of available source languages. - - :param skip_cache: Deprecated, and now has no effect as the - corresponding internal functionality has been removed. This - parameter will be removed in a future version. - :return: List of supported source languages. - """ - status, content, json = self._api_call("v2/languages", method="GET") - self._raise_for_status(status, content, json) - languages = json if (json and isinstance(json, list)) else [] - return [ - Language( - language["language"], - language["name"], - ) - for language in languages - ] - - def get_target_languages(self, skip_cache: bool = False) -> List[Language]: - """Request the list of available target languages. - - :param skip_cache: Deprecated, and now has no effect as the - corresponding internal functionality has been removed. This - parameter will be removed in a future version. - :return: List of supported target languages. - """ - data = {"type": "target"} - status, content, json = self._api_call( - "v2/languages", method="GET", data=data - ) - self._raise_for_status(status, content, json) - languages = json if (json and isinstance(json, list)) else [] - return [ - Language( - language["language"], - language["name"], - language.get("supports_formality", None), - ) - for language in languages - ] - - def get_glossary_languages(self) -> List[GlossaryLanguagePair]: - """Request the list of language pairs supported for glossaries.""" - status, content, json = self._api_call( - "v2/glossary-language-pairs", method="GET" - ) - - self._raise_for_status(status, content, json) - - supported_languages = ( - json.get("supported_languages", []) - if (json and isinstance(json, dict)) - else [] - ) - return [ - GlossaryLanguagePair( - language_pair["source_lang"], language_pair["target_lang"] - ) - for language_pair in supported_languages - ] - - def get_usage(self) -> Usage: - """Requests the current API usage.""" - status, content, json = self._api_call("v2/usage", method="GET") - - self._raise_for_status(status, content, json) - - if not isinstance(json, dict): - json = {} - return Usage(json) - - def create_glossary( - self, - name: str, - source_lang: Union[str, Language], - target_lang: Union[str, Language], - entries: Dict[str, str], - ) -> GlossaryInfo: - """Creates a glossary with given name for the source and target - languages, containing the entries in dictionary. The glossary may be - used in the translate_text functions. - - Only certain language pairs are supported. The available language pairs - can be queried using get_glossary_languages(). Glossaries are not - regional specific: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function requires the glossary entries to be provided as a - dictionary of source-target terms. To create a glossary from a CSV file - downloaded from the DeepL website, see create_glossary_from_csv(). - - :param name: user-defined name to attach to glossary. - :param source_lang: Language of source terms. - :param target_lang: Language of target terms. - :param entries: dictionary of terms to insert in glossary, with the - keys and values representing source and target terms respectively. - :return: GlossaryInfo containing information about created glossary. - - :raises ValueError: If the glossary name is empty, or entries are - empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ - if not entries: - raise ValueError("glossary entries must not be empty") - - return self._create_glossary( - name, - source_lang, - target_lang, - "tsv", - util.convert_dict_to_tsv(entries), - ) - - def create_glossary_from_csv( - self, - name: str, - source_lang: Union[str, Language], - target_lang: Union[str, Language], - csv_data: Union[TextIO, BinaryIO, str, bytes, Any], - ) -> GlossaryInfo: - """Creates a glossary with given name for the source and target - languages, containing the entries in the given CSV data. - The glossary may be used in the translate_text functions. - - Only certain language pairs are supported. The available language pairs - can be queried using get_glossary_languages(). Glossaries are not - regional specific: a glossary with target language EN may be used to - translate texts into both EN-US and EN-GB. - - This function allows you to upload a glossary CSV file that you have - downloaded from the DeepL website. - - Information about the expected CSV format can be found in the API - documentation: https://www.deepl.com/docs-api/managing-glossaries/supported-glossary-formats/ # noqa - - :param name: user-defined name to attach to glossary. - :param source_lang: Language of source terms. - :param target_lang: Language of target terms. - :param csv_data: CSV data containing glossary entries, either as a - file-like object or string or bytes containing file content. - :return: GlossaryInfo containing information about created glossary. - - :raises ValueError: If the glossary name is empty, or entries are - empty or invalid. - :raises DeepLException: If source and target language pair are not - supported for glossaries. - """ - - entries = ( - csv_data if isinstance(csv_data, (str, bytes)) else csv_data.read() - ) - - if not isinstance(entries, (bytes, str)): - raise ValueError("Entries of the glossary are invalid") - return self._create_glossary( - name, source_lang, target_lang, "csv", entries - ) - - def get_glossary(self, glossary_id: str) -> GlossaryInfo: - """Retrieves GlossaryInfo for the glossary with specified ID. - - :param glossary_id: ID of glossary to retrieve. - :return: GlossaryInfo with information about specified glossary. - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - """ - status, content, json = self._api_call( - f"v2/glossaries/{glossary_id}", method="GET" - ) - self._raise_for_status(status, content, json, glossary=True) - return GlossaryInfo.from_json(json) - - def list_glossaries(self) -> List[GlossaryInfo]: - """Retrieves GlossaryInfo for all available glossaries. - - :return: list of GlossaryInfo for all available glossaries. - """ - status, content, json = self._api_call("v2/glossaries", method="GET") - self._raise_for_status(status, content, json, glossary=True) - glossaries = ( - json.get("glossaries", []) - if (json and isinstance(json, dict)) - else [] - ) - return [GlossaryInfo.from_json(glossary) for glossary in glossaries] - - def get_glossary_entries(self, glossary: Union[str, GlossaryInfo]) -> dict: - """Retrieves the entries of the specified glossary and returns them as - a dictionary. - - :param glossary: GlossaryInfo or ID of glossary to retrieve. - :return: dictionary of glossary entries. - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - :raises DeepLException: If the glossary could not be retrieved - in the right format. - """ - if isinstance(glossary, GlossaryInfo): - glossary_id = glossary.glossary_id - else: - glossary_id = glossary - - status, content, json = self._api_call( - f"v2/glossaries/{glossary_id}/entries", - method="GET", - headers={"Accept": "text/tab-separated-values"}, - ) - self._raise_for_status(status, content, json, glossary=True) - if not isinstance(content, str): - raise DeepLException( - "Could not get the glossary content as a string", - http_status_code=status, - ) - return util.convert_tsv_to_dict(content) - - def delete_glossary(self, glossary: Union[str, GlossaryInfo]) -> None: - """Deletes specified glossary. - - :param glossary: GlossaryInfo or ID of glossary to delete. - :raises GlossaryNotFoundException: If no glossary with given ID is - found. - """ - if isinstance(glossary, GlossaryInfo): - glossary_id = glossary.glossary_id - else: - glossary_id = glossary - - status, content, json = self._api_call( - f"v2/glossaries/{glossary_id}", - method="DELETE", - ) - self._raise_for_status(status, content, json, glossary=True) diff --git a/poetry.lock b/poetry.lock index e3471e1..0149208 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,217 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +description = "Happy Eyeballs for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, +] +markers = {main = "extra == \"async\""} + +[[package]] +name = "aiohttp" +version = "3.13.3" +description = "Async http client/server framework (asyncio)" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821"}, + {file = "aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455"}, + {file = "aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29"}, + {file = "aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11"}, + {file = "aiohttp-3.13.3-cp310-cp310-win32.whl", hash = "sha256:37da61e244d1749798c151421602884db5270faf479cf0ef03af0ff68954c9dd"}, + {file = "aiohttp-3.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:7e63f210bc1b57ef699035f2b4b6d9ce096b5914414a49b0997c839b2bd2223c"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64"}, + {file = "aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1"}, + {file = "aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4"}, + {file = "aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29"}, + {file = "aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239"}, + {file = "aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168"}, + {file = "aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc"}, + {file = "aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce"}, + {file = "aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a"}, + {file = "aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046"}, + {file = "aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9"}, + {file = "aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0"}, + {file = "aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591"}, + {file = "aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf"}, + {file = "aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415"}, + {file = "aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1"}, + {file = "aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c"}, + {file = "aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43"}, + {file = "aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1"}, + {file = "aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592"}, + {file = "aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8"}, + {file = "aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df"}, + {file = "aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767"}, + {file = "aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31a83ea4aead760dfcb6962efb1d861db48c34379f2ff72db9ddddd4cda9ea2e"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:988a8c5e317544fdf0d39871559e67b6341065b87fceac641108c2096d5506b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9b174f267b5cfb9a7dba9ee6859cecd234e9a681841eb85068059bc867fb8f02"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:947c26539750deeaee933b000fb6517cc770bbd064bad6033f1cff4803881e43"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9ebf57d09e131f5323464bd347135a88622d1c0976e88ce15b670e7ad57e4bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4ae5b5a0e1926e504c81c5b84353e7a5516d8778fbbff00429fe7b05bb25cbce"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ba0eea45eb5cc3172dbfc497c066f19c41bac70963ea1a67d51fc92e4cf9a80"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bae5c2ed2eae26cc382020edad80d01f36cb8e746da40b292e68fec40421dc6a"}, + {file = "aiohttp-3.13.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a60e60746623925eab7d25823329941aee7242d559baa119ca2b253c88a7bd6"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e50a2e1404f063427c9d027378472316201a2290959a295169bcf25992d04558"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:9a9dc347e5a3dc7dfdbc1f82da0ef29e388ddb2ed281bfce9dd8248a313e62b7"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b46020d11d23fe16551466c77823df9cc2f2c1e63cc965daf67fa5eec6ca1877"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:69c56fbc1993fa17043e24a546959c0178fe2b5782405ad4559e6c13975c15e3"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b99281b0704c103d4e11e72a76f1b543d4946fea7dd10767e7e1b5f00d4e5704"}, + {file = "aiohttp-3.13.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:40c5e40ecc29ba010656c18052b877a1c28f84344825efa106705e835c28530f"}, + {file = "aiohttp-3.13.3-cp39-cp39-win32.whl", hash = "sha256:56339a36b9f1fc708260c76c87e593e2afb30d26de9ae1eb445b5e051b98a7a1"}, + {file = "aiohttp-3.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:c6b8568a3bb5819a0ad087f16d40e5a3fb6099f39ea1d5625a3edc1e923fc538"}, + {file = "aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88"}, +] +markers = {main = "extra == \"async\""} + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" +async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} +attrs = ">=17.3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +propcache = ">=0.2.0" +yarl = ">=1.17.0,<2.0" + +[package.extras] +speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] + +[[package]] +name = "aiosignal" +version = "1.4.0" +description = "aiosignal: a list of registered asynchronous callbacks" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, +] +markers = {main = "extra == \"async\""} + +[package.dependencies] +frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main", "async-dev"] +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] +markers = {main = "extra == \"async\" and python_version < \"3.11\"", async-dev = "python_version < \"3.11\""} + +[[package]] +name = "attrs" +version = "25.4.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, + {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, +] +markers = {main = "extra == \"async\""} + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["async-dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] [[package]] name = "backports-tarfile" @@ -7,7 +220,7 @@ description = "Backport of CPython tarfile module" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "python_version < \"3.12\" and extra == \"keyring\"" +markers = "extra == \"keyring\" and python_version < \"3.12\"" files = [ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, @@ -24,7 +237,6 @@ description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e"}, {file = "black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0"}, @@ -70,58 +282,6 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] -[[package]] -name = "black" -version = "25.12.0" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "black-25.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"}, - {file = "black-25.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a"}, - {file = "black-25.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea"}, - {file = "black-25.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f"}, - {file = "black-25.12.0-cp310-cp310-win_arm64.whl", hash = "sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da"}, - {file = "black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a"}, - {file = "black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be"}, - {file = "black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b"}, - {file = "black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5"}, - {file = "black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655"}, - {file = "black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a"}, - {file = "black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783"}, - {file = "black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59"}, - {file = "black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892"}, - {file = "black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43"}, - {file = "black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5"}, - {file = "black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f"}, - {file = "black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf"}, - {file = "black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d"}, - {file = "black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce"}, - {file = "black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5"}, - {file = "black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f"}, - {file = "black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f"}, - {file = "black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83"}, - {file = "black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b"}, - {file = "black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828"}, - {file = "black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=0.9.0" -platformdirs = ">=2" -pytokens = ">=0.3.0" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2)"] - [[package]] name = "certifi" version = "2025.11.12" @@ -141,7 +301,7 @@ description = "Foreign Function Interface for Python calling C code." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and extra == \"keyring\" and sys_platform == \"linux\"" +markers = "extra == \"keyring\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -362,7 +522,6 @@ description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -371,34 +530,18 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -[[package]] -name = "click" -version = "8.3.1" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, - {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +groups = ["async-dev", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {async-dev = "sys_platform == \"win32\"", dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "coverage" @@ -407,7 +550,6 @@ description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, @@ -518,112 +660,6 @@ files = [ [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] -[[package]] -name = "coverage" -version = "7.13.1" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"}, - {file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"}, - {file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"}, - {file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"}, - {file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"}, - {file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"}, - {file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"}, - {file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"}, - {file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"}, - {file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"}, - {file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"}, - {file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"}, - {file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"}, - {file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"}, - {file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"}, - {file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"}, - {file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"}, - {file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"}, - {file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"}, - {file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"}, - {file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"}, - {file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"}, - {file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"}, - {file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"}, - {file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"}, - {file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"}, - {file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"}, - {file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"}, - {file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"}, - {file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"}, - {file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"}, - {file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"}, - {file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"}, - {file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"}, - {file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"}, - {file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"}, - {file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"}, - {file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"}, - {file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"}, - {file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"}, - {file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"}, - {file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"}, - {file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"}, - {file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - [[package]] name = "cryptography" version = "43.0.3" @@ -631,7 +667,7 @@ description = "cryptography is a package which provides cryptographic recipes an optional = true python-versions = ">=3.7" groups = ["main"] -markers = "(python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\") and extra == \"keyring\" and sys_platform == \"linux\"" +markers = "extra == \"keyring\" and sys_platform == \"linux\"" files = [ {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, @@ -675,91 +711,13 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] -[[package]] -name = "cryptography" -version = "46.0.3" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = true -python-versions = "!=3.9.0,!=3.9.1,>=3.8" -groups = ["main"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\" and extra == \"keyring\" and sys_platform == \"linux\"" -files = [ - {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, - {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, - {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, - {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, - {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, - {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, - {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, - {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, - {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, - {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, - {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, - {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, - {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, - {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, - {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, - {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, - {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, - {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, - {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, - {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - [[package]] name = "exceptiongroup" version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["async-dev", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, @@ -789,13 +747,154 @@ mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.14.0,<2.15.0" pyflakes = ">=3.4.0,<3.5.0" +[[package]] +name = "frozenlist" +version = "1.8.0" +description = "A list-like structure which implements collections.abc.MutableSequence" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, + {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, + {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, + {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, + {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, + {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, + {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, + {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, + {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, + {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, + {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, + {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, + {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, + {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, + {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, + {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, + {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, + {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, + {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, + {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, + {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, + {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, + {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, + {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, + {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, + {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, + {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, + {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, + {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, + {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, + {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, + {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, + {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, + {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, + {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, + {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, + {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, + {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, + {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, + {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, +] +markers = {main = "extra == \"async\""} + [[package]] name = "idna" version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "async-dev"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -811,7 +910,7 @@ description = "Read metadata from Python packages" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.12\" and extra == \"keyring\"" +markers = "extra == \"keyring\" and python_version < \"3.12\"" files = [ {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, @@ -835,26 +934,12 @@ version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" -groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" +groups = ["async-dev", "dev"] files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1083,6 +1168,166 @@ files = [ {file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"}, ] +[[package]] +name = "multidict" +version = "6.7.1" +description = "multidict implementation" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, + {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, + {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, + {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, + {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, + {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, + {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, + {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, + {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, + {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, + {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, + {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, + {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, + {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, + {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, + {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, + {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, + {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, + {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, + {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, + {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, + {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, + {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, + {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, + {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, + {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, + {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, + {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, + {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, + {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, + {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, + {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, + {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, + {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, + {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, + {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, + {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, + {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, + {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, + {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, + {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, + {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, + {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, + {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, + {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, + {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, + {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, + {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, + {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, + {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, + {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, +] +markers = {main = "extra == \"async\""} + +[package.dependencies] +typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} + [[package]] name = "mypy" version = "1.19.1" @@ -1163,7 +1408,7 @@ version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["async-dev", "dev"] files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -1188,7 +1433,6 @@ description = "A small Python package for determining appropriate platform-speci optional = false python-versions = ">=3.9" groups = ["dev"] -markers = "python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\"" files = [ {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, @@ -1199,31 +1443,13 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.14.1)"] -[[package]] -name = "platformdirs" -version = "4.5.1" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\"" -files = [ - {file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"}, - {file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"}, -] - -[package.extras] -docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"] -type = ["mypy (>=1.18.2)"] - [[package]] name = "pluggy" version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["async-dev", "dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -1233,6 +1459,139 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "propcache" +version = "0.4.1" +description = "Accelerated property cache" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8"}, + {file = "propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db"}, + {file = "propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900"}, + {file = "propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c"}, + {file = "propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb"}, + {file = "propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37"}, + {file = "propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5"}, + {file = "propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc"}, + {file = "propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757"}, + {file = "propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f"}, + {file = "propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1"}, + {file = "propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6"}, + {file = "propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403"}, + {file = "propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4"}, + {file = "propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9"}, + {file = "propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75"}, + {file = "propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8"}, + {file = "propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db"}, + {file = "propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311"}, + {file = "propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c"}, + {file = "propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61"}, + {file = "propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66"}, + {file = "propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81"}, + {file = "propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e"}, + {file = "propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566"}, + {file = "propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b"}, + {file = "propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7"}, + {file = "propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1"}, + {file = "propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717"}, + {file = "propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37"}, + {file = "propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c"}, + {file = "propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44"}, + {file = "propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49"}, + {file = "propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144"}, + {file = "propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f"}, + {file = "propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153"}, + {file = "propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393"}, + {file = "propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc"}, + {file = "propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36"}, + {file = "propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455"}, + {file = "propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85"}, + {file = "propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1"}, + {file = "propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb"}, + {file = "propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a"}, + {file = "propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781"}, + {file = "propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183"}, + {file = "propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19"}, + {file = "propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f"}, + {file = "propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938"}, + {file = "propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237"}, + {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, +] +markers = {main = "extra == \"async\""} + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1252,7 +1611,7 @@ description = "C parser in Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and extra == \"keyring\" and sys_platform == \"linux\" and implementation_name != \"PyPy\"" +markers = "extra == \"keyring\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, @@ -1330,7 +1689,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["async-dev", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1345,7 +1704,7 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["async-dev", "dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -1363,6 +1722,27 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["async-dev"] +files = [ + {file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"}, + {file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"}, +] + +[package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<9" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytokens" version = "0.3.0" @@ -1420,7 +1800,7 @@ description = "Python bindings to FreeDesktop.org Secret Service API" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "(python_full_version < \"3.14.0\" or platform_python_implementation == \"PyPy\") and extra == \"keyring\" and sys_platform == \"linux\"" +markers = "extra == \"keyring\" and sys_platform == \"linux\"" files = [ {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, @@ -1430,30 +1810,13 @@ files = [ cryptography = ">=2.0" jeepney = ">=0.6" -[[package]] -name = "secretstorage" -version = "3.5.0" -description = "Python bindings to FreeDesktop.org Secret Service API" -optional = true -python-versions = ">=3.10" -groups = ["main"] -markers = "python_full_version >= \"3.14.0\" and platform_python_implementation != \"PyPy\" and extra == \"keyring\" and sys_platform == \"linux\"" -files = [ - {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, - {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, -] - -[package.dependencies] -cryptography = ">=2.0" -jeepney = ">=0.6" - [[package]] name = "tomli" version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["async-dev", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, @@ -1506,11 +1869,12 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "async-dev", "dev"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +markers = {main = "extra == \"async\" and python_version < \"3.13\"", async-dev = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1530,6 +1894,152 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "yarl" +version = "1.22.0" +description = "Yet another URL library" +optional = false +python-versions = ">=3.9" +groups = ["main", "async-dev"] +files = [ + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f"}, + {file = "yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb"}, + {file = "yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737"}, + {file = "yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467"}, + {file = "yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea"}, + {file = "yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca"}, + {file = "yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6"}, + {file = "yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e"}, + {file = "yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6"}, + {file = "yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e"}, + {file = "yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca"}, + {file = "yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b"}, + {file = "yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2"}, + {file = "yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82"}, + {file = "yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d"}, + {file = "yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520"}, + {file = "yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8"}, + {file = "yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c"}, + {file = "yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a"}, + {file = "yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2"}, + {file = "yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02"}, + {file = "yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67"}, + {file = "yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95"}, + {file = "yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d"}, + {file = "yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3"}, + {file = "yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708"}, + {file = "yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f"}, + {file = "yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62"}, + {file = "yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03"}, + {file = "yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249"}, + {file = "yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683"}, + {file = "yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da"}, + {file = "yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd"}, + {file = "yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da"}, + {file = "yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2"}, + {file = "yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79"}, + {file = "yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca"}, + {file = "yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b"}, + {file = "yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093"}, + {file = "yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c"}, + {file = "yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e"}, + {file = "yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27"}, + {file = "yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859"}, + {file = "yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890"}, + {file = "yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e"}, + {file = "yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8"}, + {file = "yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b"}, + {file = "yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed"}, + {file = "yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2"}, + {file = "yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff"}, + {file = "yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71"}, +] +markers = {main = "extra == \"async\""} + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +propcache = ">=0.2.1" + [[package]] name = "zipp" version = "3.23.0" @@ -1537,7 +2047,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "python_version < \"3.12\" and extra == \"keyring\"" +markers = "extra == \"keyring\" and python_version < \"3.12\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, @@ -1552,9 +2062,10 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy"] [extras] +async = ["aiohttp"] keyring = ["keyring"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4" -content-hash = "1030c5492c7434658508cdc6bec2e382d725b613dba860d09ff7789785532ad5" +content-hash = "4e6c83b6167618a0cfcd18a3497ec77811110666b97227ad70df077de20d1e00" diff --git a/pyproject.toml b/pyproject.toml index f63cb19..5758f59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ fail_under = 70 [tool.poetry.dependencies] python = ">=3.9,<4" requests = ">=2.32.5" +aiohttp = {version = ">=3.9.0", optional = true} keyring = {version = "^25.7.0", optional = true} [tool.poetry.group.dev.dependencies] @@ -44,9 +45,21 @@ mypy = "^1.17.1" pytest = "^8.4.2" coverage = "^7.10.6" +[tool.poetry.group.async-dev.dependencies] +pytest-asyncio = ">=0.23" +aiohttp = ">=3.9.0" + [tool.poetry.extras] +async = ["aiohttp"] keyring = ["keyring"] +[[tool.mypy.overrides]] +module = ["aiohttp", "aiohttp.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.poetry.scripts] deepl = "deepl.__main__:main" diff --git a/tests/conftest.py b/tests/conftest.py index c5ae388..db0dae9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -135,13 +135,16 @@ def expect_proxy(self, value: bool = True): return Server() -def _make_translator(server, auth_key=None, proxy=None): +def _make_translator(server, auth_key=None, proxy=None, retry_config=None): """Returns a deepl.Translator for the specified server test fixture. The server auth_key is used unless specifically overridden.""" if auth_key is None: auth_key = server.auth_key + kwargs = {} + if retry_config is not None: + kwargs["retry_config"] = retry_config translator = deepl.Translator( - auth_key, server_url=server.server_url, proxy=proxy + auth_key, server_url=server.server_url, proxy=proxy, **kwargs ) # If the server test fixture has custom headers defined, update the @@ -154,6 +157,22 @@ def _make_translator(server, auth_key=None, proxy=None): return translator +def _make_async_client(server, auth_key=None, proxy=None, retry_config=None): + """Returns a deepl.DeepLClientAsync for the specified server.""" + if auth_key is None: + auth_key = server.auth_key + kwargs = {} + if retry_config is not None: + kwargs["retry_config"] = retry_config + client = deepl.DeepLClientAsync( + auth_key, server_url=server.server_url, proxy=proxy, **kwargs + ) + if server.headers: + server.headers.update(client.headers) + client.headers = server.headers + return client + + def _make_deepl_client(server, auth_key=None, proxy=None): """Returns a deepl.DeepLClient for the specified server test fixture. The server auth_key is used unless specifically overridden.""" @@ -189,6 +208,16 @@ def deepl_client(server): return _make_deepl_client(server) +@pytest.fixture +async def async_translator(server): + """Returns a deepl.DeepLClientAsync for all async tests.""" + client = _make_async_client(server) + try: + yield client + finally: + await client.close() + + @pytest.fixture def translator_with_random_auth_key(server): """Returns a deepl.Translator with randomized authentication key, @@ -232,6 +261,15 @@ def do_cleanup(predicate: Callable[[deepl.GlossaryInfo], bool]): return do_cleanup +@pytest.fixture +def mock_http_client(): + """Returns a fresh MockHttpClient for unit tests that don't hit the + network.""" + from .mock_http_client import MockHttpClient + + return MockHttpClient() + + class ManagedGlossary: """ Utility content-manager class to create a test glossary and ensure its diff --git a/tests/mock_http_client.py b/tests/mock_http_client.py new file mode 100644 index 0000000..dab8569 --- /dev/null +++ b/tests/mock_http_client.py @@ -0,0 +1,114 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +"""Synchronous mock HTTP client for unit/retry tests.""" + +import json +from collections import deque +from typing import Deque, Iterator, List, Optional, Union + +from deepl._http_types import HttpRequest, HttpResponse +from deepl.exceptions import ConnectionException + + +class MockStreamingResponse: + """Minimal streaming response returned by MockHttpClient.send_streaming.""" + + def __init__( + self, status_code: int, headers: dict, content: bytes + ) -> None: + self.status_code = status_code + self.headers = headers + self._content = content + + def iter_content(self, chunk_size: int = 65536) -> Iterator[bytes]: + yield self._content + + +# A response queue entry is either an HttpResponse or a ConnectionException. +QueueEntry = Union[HttpResponse, ConnectionException] + + +class MockHttpClient: + """Configurable HTTP client for testing retry and error-mapping logic. + + Responses are consumed from ``responses`` in order. Each call to + ``send()`` pops the next entry; if it is a :class:`ConnectionException` + it is raised, otherwise the :class:`HttpResponse` is returned. + + Attributes + ---------- + calls: + List of :class:`HttpRequest` objects received by ``send()``. + """ + + http_library_info: str = "mock/0.0" + + def __init__(self, responses: Optional[List[QueueEntry]] = None) -> None: + self._responses: Deque[QueueEntry] = deque(responses or []) + self.calls: List[HttpRequest] = [] + + def push(self, *entries: QueueEntry) -> None: + """Append one or more response entries to the queue.""" + self._responses.extend(entries) + + @staticmethod + def ok_response(body: dict, status: int = 200) -> HttpResponse: + """Helper: build a 200 JSON response.""" + content = json.dumps(body).encode("utf-8") + return HttpResponse( + status_code=status, + headers={"Content-Type": "application/json"}, + content=content, + ) + + @staticmethod + def status_response(status: int, message: str = "") -> HttpResponse: + """Helper: build a non-2xx response with an optional message body.""" + body = {"message": message} if message else {} + content = json.dumps(body).encode("utf-8") + return HttpResponse( + status_code=status, + headers={"Content-Type": "application/json"}, + content=content, + ) + + @staticmethod + def connection_error( + message: str = "connection failed", *, should_retry: bool = True + ) -> ConnectionException: + """Helper: build a ConnectionException.""" + return ConnectionException(message, should_retry=should_retry) + + def send(self, request: HttpRequest) -> HttpResponse: + self.calls.append(request) + if not self._responses: + raise AssertionError( + f"MockHttpClient.send() called but response queue is empty " + f"(call #{len(self.calls)})" + ) + entry = self._responses.popleft() + if isinstance(entry, Exception): + raise entry + return entry + + def send_streaming(self, request: HttpRequest) -> MockStreamingResponse: + self.calls.append(request) + if not self._responses: + raise AssertionError( + f"MockHttpClient.send_streaming() called but queue is empty " + f"(call #{len(self.calls)})" + ) + entry = self._responses.popleft() + if isinstance(entry, Exception): + raise entry + assert isinstance(entry, HttpResponse) + return MockStreamingResponse( + status_code=entry.status_code, + headers=entry.headers, + content=entry.content, + ) + + def close(self) -> None: + pass diff --git a/tests/test_aiohttp_client_lifecycle.py b/tests/test_aiohttp_client_lifecycle.py new file mode 100644 index 0000000..93cd2f7 --- /dev/null +++ b/tests/test_aiohttp_client_lifecycle.py @@ -0,0 +1,117 @@ +# Copyright 2026 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +"""Unit tests for resource-lifecycle behaviour in AioHttpClient. +- streaming/buffered responses must `release()` (not sync `close()`) so + the aiohttp connection pool can reclaim sockets. +- sessions abandoned on event-loop change must not be silently leaked + via `loop.create_task()`; they're queued and drained in `close()`. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +pytest.importorskip("aiohttp") + +from deepl.aiohttp_client import ( # noqa: E402 + AioHttpClient, + _AioHttpStreamingResponse, +) + +pytestmark = pytest.mark.asyncio + + +async def test_streaming_response_close_awaits_release(): + resp = MagicMock() + resp.status = 200 + resp.headers = {} + resp.release = AsyncMock() + resp.close = MagicMock() + + streaming = _AioHttpStreamingResponse(resp) + await streaming.close() + + resp.release.assert_awaited_once() + resp.close.assert_not_called() + + +async def test_streaming_response_async_context_manager_releases(): + resp = MagicMock() + resp.status = 200 + resp.headers = {} + resp.release = AsyncMock() + + streaming = _AioHttpStreamingResponse(resp) + async with streaming as entered: + assert entered is streaming + resp.release.assert_not_awaited() + + resp.release.assert_awaited_once() + + +async def test_streaming_response_async_context_manager_releases_on_error(): + resp = MagicMock() + resp.status = 200 + resp.headers = {} + resp.release = AsyncMock() + + streaming = _AioHttpStreamingResponse(resp) + with pytest.raises(RuntimeError, match="boom"): + async with streaming: + raise RuntimeError("boom") + + resp.release.assert_awaited_once() + + +async def test_stale_session_queued_not_fire_and_forget(monkeypatch): + import deepl.aiohttp_client as mod + + new_session = MagicMock() + new_session.closed = False + new_session.close = AsyncMock() + monkeypatch.setattr(mod.aiohttp, "ClientSession", lambda **kw: new_session) + monkeypatch.setattr(mod.aiohttp, "TCPConnector", lambda **kw: MagicMock()) + + client = AioHttpClient() + old_session = MagicMock() + old_session.closed = False + old_session.close = AsyncMock() + client._session = old_session + # Distinct sentinel — any value other than the current running loop + # makes `_get_session` treat the prior session as stale. + client._session_loop = MagicMock() + + client._get_session() + + assert old_session in client._pending_close_sessions + old_session.close.assert_not_called() + assert client._session is new_session + + +async def test_close_drains_pending_sessions(): + client = AioHttpClient() + s1, s2 = MagicMock(), MagicMock() + for s in (s1, s2): + s.closed = False + s.close = AsyncMock() + client._pending_close_sessions = [s1, s2] + + await client.close() + + s1.close.assert_awaited_once() + s2.close.assert_awaited_once() + assert client._pending_close_sessions == [] + + +async def test_close_swallows_errors_from_dead_loop_sessions(): + client = AioHttpClient() + bad = MagicMock() + bad.closed = False + bad.close = AsyncMock(side_effect=RuntimeError("loop is closed")) + client._pending_close_sessions = [bad] + + await client.close() # must not raise + + assert client._pending_close_sessions == [] diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..b6364c0 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,188 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +import gc +import warnings + +import pytest + +pytest.importorskip("aiohttp") + +import deepl # noqa: E402 +from deepl.retry_config import RetryConfig # noqa: E402 + +from .conftest import ( # noqa: E402 + _make_async_client, + example_text, + needs_mock_server, +) + +pytestmark = pytest.mark.asyncio + +default_lang_args = {"target_lang": "DE", "source_lang": "EN"} + + +async def test_translate_text(async_translator): + result = await async_translator.translate_text( + example_text["EN"], target_lang="DE" + ) + assert example_text["DE"] == result.text + assert "EN" == result.detected_source_lang + + +async def test_usage(async_translator): + usage = await async_translator.get_usage() + assert "Usage this billing period" in str(usage) + + +async def test_translate_with_enums(async_translator): + result = await async_translator.translate_text( + example_text["EN"], + source_lang=deepl.Language.ENGLISH, + target_lang=deepl.Language.GERMAN, + ) + assert example_text["DE"] == result.text + + +async def test_invalid_authkey(server): + async with deepl.DeepLClientAsync( + "invalid", server_url=server.server_url + ) as translator: + with pytest.raises(deepl.exceptions.AuthorizationException): + await translator.get_usage() + + +@needs_mock_server +async def test_translate_document_from_filepath( + server, + example_document_path, + example_document_translation, + output_document_path, +): + server.set_doc_queue_time(2000) + server.set_doc_translate_time(2000) + translator = _make_async_client( + server, retry_config=RetryConfig(min_connection_timeout=1.0) + ) + async with translator: + status = await translator.translate_document_from_filepath( + example_document_path, + output_path=output_document_path, + **default_lang_args, + ) + assert example_document_translation == output_document_path.read_text() + assert status.done + + +@needs_mock_server +async def test_translate_document_with_retry( + server, + example_document_path, + example_document_translation, + output_document_path, +): + server.no_response(1) + server.set_doc_queue_time(2000) + server.set_doc_translate_time(2000) + + translator = _make_async_client( + server, retry_config=RetryConfig(min_connection_timeout=1.0) + ) + async with translator: + await translator.translate_document_from_filepath( + example_document_path, + output_path=output_document_path, + **default_lang_args, + ) + assert example_document_translation == output_document_path.read_text() + + +@needs_mock_server +async def test_translate_document_low_level( + async_translator, + example_document_path, + example_document_translation, + output_document_path, + server, +): + server.set_doc_queue_time(100) + + with open(example_document_path, "rb") as infile: + handle = await async_translator.translate_document_upload( + infile, **default_lang_args + ) + status = await async_translator.translate_document_get_status(handle) + assert status.ok and not status.done + + doc_id, doc_key = handle.document_id, handle.document_key + del handle + + handle = deepl.DocumentHandle(doc_id, doc_key) + status = await async_translator.translate_document_get_status(handle) + assert status.ok + + while status.ok and not status.done: + status = await async_translator.translate_document_get_status(handle) + + assert status.ok and status.done + with open(output_document_path, "wb") as outfile: + await async_translator.translate_document_download(handle, outfile) + + assert output_document_path.read_text() == example_document_translation + + +async def test_source_and_target_languages(async_translator): + source_languages = await async_translator.get_source_languages() + for lang in source_languages: + if lang.code == "EN": + assert lang.name == "English" + + target_languages = await async_translator.get_target_languages() + for lang in target_languages: + if lang.code == "DE": + assert lang.supports_formality + + +async def test_glossary_languages(async_translator): + pairs = await async_translator.get_glossary_languages() + assert len(pairs) > 0 + + +async def test_del_warns_when_not_closed(): + client = deepl.DeepLClientAsync("dummy-key") + with pytest.warns(ResourceWarning, match="not closed"): + del client + gc.collect() + + +async def test_no_warning_after_explicit_close(): + client = deepl.DeepLClientAsync("dummy-key") + await client.close() + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + del client + gc.collect() + + +async def test_create_and_delete_glossary(async_translator, glossary_name): + entries = {"Hello": "Hallo", "world": "Welt"} + glossary = await async_translator.create_glossary( + glossary_name, source_lang="EN", target_lang="DE", entries=entries + ) + assert glossary.name == glossary_name + assert glossary.entry_count == len(entries) + + returned = await async_translator.get_glossary(glossary.glossary_id) + assert returned.glossary_id == glossary.glossary_id + + glossaries = await async_translator.list_glossaries() + assert any(g.glossary_id == glossary.glossary_id for g in glossaries) + + returned_entries = await async_translator.get_glossary_entries(glossary) + assert returned_entries == entries + + await async_translator.delete_glossary(glossary) + + with pytest.raises(deepl.GlossaryNotFoundException): + await async_translator.get_glossary(glossary.glossary_id) diff --git a/tests/test_general.py b/tests/test_general.py index cf1afce..e6055ac 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -3,6 +3,7 @@ # license that can be found in the LICENSE file. from .conftest import ( + _make_translator, example_text, needs_mock_server, needs_mock_proxy_server, @@ -165,13 +166,15 @@ def test_user_agent_opt_out(mock_send): @patch("requests.adapters.HTTPAdapter.send") def test_custom_user_agent(mock_send): mock_send.return_value = _build_test_response() - old_user_agent = deepl.http_client.user_agent - deepl.http_client.user_agent = "my custom user agent" - translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) - translator.translate_text(example_text["EN"], target_lang="DA") - ua_header = mock_send.call_args[0][0].headers["User-agent"] - assert ua_header == "my custom user agent" - deepl.http_client.user_agent = old_user_agent + with pytest.warns(DeprecationWarning, match="user_agent"): + deepl.http_client.user_agent = "my custom user agent" + try: + translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert ua_header == "my custom user agent" + finally: + deepl.http_client.user_agent = None @patch("requests.adapters.HTTPAdapter.send") @@ -206,15 +209,56 @@ def test_user_agent_opt_out_with_app_info(mock_send): @patch("requests.adapters.HTTPAdapter.send") def test_custom_user_agent_with_app_info(mock_send): mock_send.return_value = _build_test_response() - old_user_agent = deepl.http_client.user_agent - deepl.http_client.user_agent = "my custom user agent" - translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]).set_app_info( - "sample_python_plugin", "1.0.2" + with pytest.warns(DeprecationWarning, match="user_agent"): + deepl.http_client.user_agent = "my custom user agent" + try: + translator = deepl.Translator( + os.environ["DEEPL_AUTH_KEY"] + ).set_app_info("sample_python_plugin", "1.0.2") + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert ua_header == "my custom user agent sample_python_plugin/1.0.2" + finally: + deepl.http_client.user_agent = None + + +@patch("requests.adapters.HTTPAdapter.send") +def test_set_user_agent(mock_send): + mock_send.return_value = _build_test_response() + translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]).set_user_agent( + "my-proxy/1.0" ) translator.translate_text(example_text["EN"], target_lang="DA") ua_header = mock_send.call_args[0][0].headers["User-agent"] - assert ua_header == "my custom user agent sample_python_plugin/1.0.2" - deepl.http_client.user_agent = old_user_agent + assert ua_header == "my-proxy/1.0" + + +@patch("requests.adapters.HTTPAdapter.send") +def test_set_user_agent_with_app_info(mock_send): + mock_send.return_value = _build_test_response() + translator = ( + deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) + .set_user_agent("my-proxy/1.0") + .set_app_info("plugin", "1.2") + ) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert ua_header == "my-proxy/1.0 plugin/1.2" + + +def test_http_client_user_agent_deprecated(): + with pytest.warns(DeprecationWarning, match="user_agent"): + deepl.http_client.user_agent = "something" + deepl.http_client.user_agent = None # restore without warning + + +@patch("requests.adapters.HTTPAdapter.send") +def test_user_agent_contains_http_library_info(mock_send): + mock_send.return_value = _build_test_response() + translator = deepl.Translator(os.environ["DEEPL_AUTH_KEY"]) + translator.translate_text(example_text["EN"], target_lang="DA") + ua_header = mock_send.call_args[0][0].headers["User-agent"] + assert "requests/" in ua_header @patch("requests.adapters.HTTPAdapter.send") @@ -246,24 +290,29 @@ def test_proxy_usage( @needs_mock_server -def test_usage_no_response(translator, server, monkeypatch): +def test_usage_no_response(server): server.no_response(2) - # Lower the retry count and timeout for this test, and restore after test - monkeypatch.setattr(deepl.http_client, "max_network_retries", 0) - monkeypatch.setattr(deepl.http_client, "min_connection_timeout", 1.0) - + translator = _make_translator( + server, + retry_config=deepl.RetryConfig( + max_retries=0, min_connection_timeout=1.0 + ), + ) with pytest.raises(deepl.exceptions.ConnectionException): translator.get_usage() @needs_mock_server -def test_translate_too_many_requests(translator, server, monkeypatch): +def test_translate_too_many_requests(server): server.respond_with_429(2) - # Lower the retry count and timeout for this test, and restore after test - monkeypatch.setattr(deepl.http_client, "max_network_retries", 1) - monkeypatch.setattr(deepl.http_client, "min_connection_timeout", 1.0) + translator = _make_translator( + server, + retry_config=deepl.RetryConfig( + max_retries=1, min_connection_timeout=1.0 + ), + ) with pytest.raises(deepl.exceptions.TooManyRequestsException): translator.translate_text(example_text["EN"], target_lang="DE") @@ -358,6 +407,7 @@ def _build_test_response(): "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", } + response.content = response.text.encode("utf-8") response.encoding = "utf-8" response.history = None response.raw = None diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 0000000..17811ad --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,306 @@ +# Copyright 2025 DeepL SE (https://www.deepl.com) +# Use of this source code is governed by an MIT +# license that can be found in the LICENSE file. + +"""Unit tests for DeepLClient retry/error-mapping logic using MockHttpClient. + +These tests run without any network access and without real sleeping. +""" + +import pytest +import deepl +from deepl.exceptions import ( + AuthorizationException, + ConnectionException, + QuotaExceededException, + TooManyRequestsException, + DeepLException, +) +from deepl.retry_config import RetryConfig +from .mock_http_client import MockHttpClient + + +AUTH_KEY = "test-auth-key:fx" +TRANSLATE_RESPONSE = { + "translations": [ + { + "detected_source_language": "EN", + "text": "Protonstrahlen", + "billed_characters": 11, + } + ] +} +USAGE_RESPONSE = { + "character_count": 0, + "character_limit": 100, +} + + +def _make_client( + mock: MockHttpClient, max_retries: int = 3 +) -> deepl.DeepLClient: + """Return a DeepLClient backed by mock with sleep disabled.""" + config = RetryConfig(max_retries=max_retries) + client = deepl.DeepLClient( + AUTH_KEY, + http_client=mock, + retry_config=config, + _sleep_fn=lambda _: None, + ) + return client + + +# --------------------------------------------------------------------------- +# Basic success +# --------------------------------------------------------------------------- + + +def test_success_on_first_attempt(): + mock = MockHttpClient() + mock.push(MockHttpClient.ok_response(USAGE_RESPONSE)) + client = _make_client(mock) + usage = client.get_usage() + assert usage.character.count == 0 + assert len(mock.calls) == 1 + + +# --------------------------------------------------------------------------- +# ConnectionException retries +# --------------------------------------------------------------------------- + + +def test_retries_on_retryable_connection_error(): + mock = MockHttpClient() + mock.push( + MockHttpClient.connection_error(should_retry=True), + MockHttpClient.connection_error(should_retry=True), + MockHttpClient.ok_response(USAGE_RESPONSE), + ) + client = _make_client(mock, max_retries=3) + usage = client.get_usage() + assert usage.character.count == 0 + assert len(mock.calls) == 3 + + +def test_no_retry_on_non_retryable_connection_error(): + mock = MockHttpClient() + mock.push(MockHttpClient.connection_error(should_retry=False)) + client = _make_client(mock) + with pytest.raises(ConnectionException): + client.get_usage() + assert len(mock.calls) == 1 + + +def test_exhausts_retries_on_persistent_connection_error(): + max_retries = 2 + mock = MockHttpClient() + for _ in range(max_retries + 1): + mock.push(MockHttpClient.connection_error(should_retry=True)) + client = _make_client(mock, max_retries=max_retries) + with pytest.raises(ConnectionException): + client.get_usage() + # 1 initial attempt + max_retries retries + assert len(mock.calls) == max_retries + 1 + + +# --------------------------------------------------------------------------- +# HTTP 429 retries +# --------------------------------------------------------------------------- + + +def test_retries_on_429(): + mock = MockHttpClient() + mock.push( + MockHttpClient.status_response(429), + MockHttpClient.ok_response(USAGE_RESPONSE), + ) + client = _make_client(mock, max_retries=3) + usage = client.get_usage() + assert usage.character.count == 0 + assert len(mock.calls) == 2 + + +def test_exhausts_retries_on_persistent_429(): + max_retries = 2 + mock = MockHttpClient() + for _ in range(max_retries + 1): + mock.push(MockHttpClient.status_response(429)) + client = _make_client(mock, max_retries=max_retries) + with pytest.raises(TooManyRequestsException): + client.get_usage() + assert len(mock.calls) == max_retries + 1 + + +# --------------------------------------------------------------------------- +# HTTP 5xx retries +# --------------------------------------------------------------------------- + + +def test_retries_on_503(): + mock = MockHttpClient() + mock.push( + MockHttpClient.status_response(503), + MockHttpClient.ok_response(USAGE_RESPONSE), + ) + client = _make_client(mock, max_retries=3) + usage = client.get_usage() + assert usage.character.count == 0 + assert len(mock.calls) == 2 + + +def test_retries_on_500(): + mock = MockHttpClient() + mock.push( + MockHttpClient.status_response(500), + MockHttpClient.status_response(500), + MockHttpClient.ok_response(USAGE_RESPONSE), + ) + client = _make_client(mock, max_retries=3) + client.get_usage() + assert len(mock.calls) == 3 + + +def test_exhausts_retries_on_persistent_500(): + max_retries = 2 + mock = MockHttpClient() + for _ in range(max_retries + 1): + mock.push(MockHttpClient.status_response(500)) + client = _make_client(mock, max_retries=max_retries) + with pytest.raises(DeepLException): + client.get_usage() + assert len(mock.calls) == max_retries + 1 + + +# --------------------------------------------------------------------------- +# No retry on 4xx error codes +# --------------------------------------------------------------------------- + + +def test_no_retry_on_403(): + mock = MockHttpClient() + mock.push(MockHttpClient.status_response(403)) + client = _make_client(mock) + with pytest.raises(AuthorizationException): + client.get_usage() + assert len(mock.calls) == 1 + + +def test_no_retry_on_456_quota_exceeded(): + mock = MockHttpClient() + mock.push(MockHttpClient.status_response(456)) + client = _make_client(mock) + with pytest.raises(QuotaExceededException): + client.get_usage() + assert len(mock.calls) == 1 + + +def test_no_retry_on_400(): + mock = MockHttpClient() + mock.push(MockHttpClient.status_response(400, "Bad request")) + client = _make_client(mock) + with pytest.raises(DeepLException): + client.get_usage() + assert len(mock.calls) == 1 + + +# --------------------------------------------------------------------------- +# Sleep injection +# --------------------------------------------------------------------------- + + +def test_sleep_called_on_retry(): + sleep_calls = [] + mock = MockHttpClient() + mock.push( + MockHttpClient.connection_error(should_retry=True), + MockHttpClient.ok_response(USAGE_RESPONSE), + ) + config = RetryConfig(max_retries=3) + client = deepl.DeepLClient( + AUTH_KEY, + http_client=mock, + retry_config=config, + _sleep_fn=lambda secs: sleep_calls.append(secs), + ) + client.get_usage() + assert len(sleep_calls) == 1 + assert sleep_calls[0] >= 0 + + +def test_no_sleep_on_first_success(): + sleep_calls = [] + mock = MockHttpClient() + mock.push(MockHttpClient.ok_response(USAGE_RESPONSE)) + config = RetryConfig(max_retries=3) + client = deepl.DeepLClient( + AUTH_KEY, + http_client=mock, + retry_config=config, + _sleep_fn=lambda secs: sleep_calls.append(secs), + ) + client.get_usage() + assert len(sleep_calls) == 0 + + +# --------------------------------------------------------------------------- +# Deprecated http_client globals +# --------------------------------------------------------------------------- + + +def test_http_client_globals_override_retry_config(monkeypatch): + """Globals set before construction are picked up into RetryConfig.""" + monkeypatch.setattr(deepl.http_client, "max_network_retries", 2) + monkeypatch.setattr(deepl.http_client, "min_connection_timeout", 7.0) + mock = MockHttpClient() + mock.push(MockHttpClient.ok_response(USAGE_RESPONSE)) + client = deepl.DeepLClient( + AUTH_KEY, http_client=mock, _sleep_fn=lambda _: None + ) + assert client._retry_config.max_retries == 2 + assert client._retry_config.min_connection_timeout == 7.0 + + +def test_http_client_globals_emit_deprecation_warning(monkeypatch): + """Assigning to the legacy globals emits a DeprecationWarning.""" + # Use setitem for cleanup (bypasses __setattr__); assign directly inside + # pytest.warns so the warning is captured within the context. + monkeypatch.setitem( + deepl.http_client.__dict__, "max_network_retries", None + ) + monkeypatch.setitem( + deepl.http_client.__dict__, "min_connection_timeout", None + ) + with pytest.warns(DeprecationWarning, match="max_network_retries"): + deepl.http_client.max_network_retries = 1 + with pytest.warns(DeprecationWarning, match="min_connection_timeout"): + deepl.http_client.min_connection_timeout = 5.0 + + +def test_http_client_globals_ignored_after_construction(monkeypatch): + """Globals set after construction have no effect on the client.""" + mock = MockHttpClient() + mock.push(MockHttpClient.ok_response(USAGE_RESPONSE)) + client = deepl.DeepLClient( + AUTH_KEY, + http_client=mock, + retry_config=RetryConfig(max_retries=3), + _sleep_fn=lambda _: None, + ) + monkeypatch.setattr(deepl.http_client, "max_network_retries", 99) + assert client._retry_config.max_retries == 3 + + +# --------------------------------------------------------------------------- +# zero max_retries +# --------------------------------------------------------------------------- + + +def test_zero_max_retries_no_retry(): + mock = MockHttpClient() + mock.push( + MockHttpClient.connection_error(should_retry=True), + ) + client = _make_client(mock, max_retries=0) + with pytest.raises(ConnectionException): + client.get_usage() + assert len(mock.calls) == 1 diff --git a/tests/test_translate_document.py b/tests/test_translate_document.py index 8a5cf92..3539919 100644 --- a/tests/test_translate_document.py +++ b/tests/test_translate_document.py @@ -3,7 +3,12 @@ # license that can be found in the LICENSE file. import re -from .conftest import example_text, needs_mock_server, needs_real_server +from .conftest import ( + _make_translator, + example_text, + needs_mock_server, + needs_real_server, +) import deepl import io import pathlib @@ -35,17 +40,17 @@ def test_translate_document_from_filepath( @needs_mock_server def test_translate_document_with_retry( - translator, server, example_document_path, example_document_translation, output_document_path, - monkeypatch, ): server.no_response(1) - # Lower the timeout for this test, and restore after test - monkeypatch.setattr(deepl.http_client, "min_connection_timeout", 1.0) + translator = _make_translator( + server, + retry_config=deepl.RetryConfig(min_connection_timeout=1.0), + ) translator.translate_document_from_filepath( example_document_path, output_path=output_document_path,