Coverage for app/venv/lib/python3.14/site-packages/weblate/machinery/deepl.py: 21%
118 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from typing import TYPE_CHECKING, ClassVar
9from dateutil.parser import isoparse
10from django.core.cache import cache
11from requests.exceptions import HTTPError, RequestException
13from .base import (
14 BatchMachineTranslation,
15 GlossaryDoesNotExistError,
16 GlossaryMachineTranslationMixin,
17 XMLMachineTranslationMixin,
18)
19from .forms import DeepLMachineryForm
21if TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true
22 from collections.abc import Iterator
24 from weblate.auth.models import User
25 from weblate.lang.models import Language
26 from weblate.trans.models import Unit
28 from .base import (
29 DownloadMultipleTranslations,
30 )
33class DeepLTranslation(
34 XMLMachineTranslationMixin, GlossaryMachineTranslationMixin, BatchMachineTranslation
35):
36 """DeepL (Linguee) machine translation support."""
38 name = "DeepL"
39 # This seems to be currently best MT service, so score it a bit
40 # better than other ones.
41 max_score = 91
42 language_map: ClassVar[dict[str, str]] = {
43 "zh_Hans": "zh",
44 "zh_Hant": "", # Traditional Chinese not supported but would map to zh
45 "pt": "pt-pt",
46 "pt@formal": "pt-pt@formal",
47 "pt@informal": "pt-pt@informal",
48 }
49 highlight_syntax = True
50 settings_form = DeepLMachineryForm
51 glossary_count_limit = 1000
53 @property
54 def api_base_url(self):
55 url = super().api_base_url
56 if self.settings["key"].endswith(":fx") and url.startswith(
57 "https://api.deepl.com/v2"
58 ):
59 return "https://api-free.deepl.com/v2"
60 return url
62 def map_language_code(self, code):
63 """Convert language to service specific code."""
64 return super().map_language_code(code).replace("_", "-").upper()
66 def get_language_possibilities(self, language: Language) -> Iterator[str]:
67 for value in super().get_language_possibilities(language):
68 yield value
69 # Add variant without suffix, this is needed for source languages
70 # as DeepL does not differentiate most language variants on the source
71 # string side.
72 if "-" in value:
73 yield value.split("-", 1)[0]
75 def get_headers(self) -> dict[str, str]:
76 return {"Authorization": f"DeepL-Auth-Key {self.settings['key']}"}
78 def get_error_message(self, exc):
79 if isinstance(exc, RequestException) and exc.response is not None:
80 try:
81 data = exc.response.json()
82 except ValueError:
83 pass
84 else:
85 try:
86 return data["message"]
87 except KeyError:
88 pass
90 if isinstance(exc, HTTPError) and exc.response.status_code == 456:
91 return "Quota exceeded. The character limit has been reached."
93 return super().get_error_message(exc)
95 def download_languages(self):
96 response = self.request(
97 "get", self.get_api_url("languages"), params={"type": "source"}
98 )
99 source_languages = {x["language"] for x in response.json()}
100 response = self.request(
101 "get", self.get_api_url("languages"), params={"type": "target"}
102 )
103 # Plain English is not listed, but is supported
104 target_languages = {"EN"}
106 # Handle formality extensions
107 for item in response.json():
108 lang_code = item["language"].upper()
109 target_languages.add(lang_code)
110 if item.get("supports_formality"):
111 target_languages.add(f"{lang_code}@FORMAL")
112 target_languages.add(f"{lang_code}@INFORMAL")
114 return (
115 (source, target)
116 for source in source_languages
117 for target in target_languages
118 )
120 def is_supported(self, source_language, target_language):
121 """Check whether given language combination is supported."""
122 return (source_language, target_language) in self.supported_languages
124 def download_multiple_translations(
125 self,
126 source_language,
127 target_language,
128 sources: list[tuple[str, Unit | None]],
129 user: User | None = None,
130 threshold: int = 75,
131 ) -> DownloadMultipleTranslations:
132 """Download list of possible translations from a service."""
133 texts = [text for text, _unit in sources]
134 unit = sources[0][1]
136 glossary_id = self.get_glossary_id(source_language, target_language, unit)
138 params = {
139 "text": texts,
140 "source_lang": source_language,
141 "target_lang": target_language,
142 "formality": self.settings.get("formality", "default"),
143 "tag_handling": "xml",
144 "ignore_tags": ["x"],
145 }
146 if context := self.settings.get("context", ""):
147 params["context"] = context
148 if target_language.endswith("@FORMAL"):
149 params["target_lang"] = target_language[:-7]
150 params["formality"] = "more"
151 elif target_language.endswith("@INFORMAL"):
152 params["target_lang"] = target_language[:-9]
153 params["formality"] = "less"
154 if glossary_id is not None:
155 params["glossary_id"] = glossary_id
156 if self.settings.get("next_gen"):
157 params["model_type"] = "prefer_quality_optimized"
159 response = self.request(
160 "post",
161 self.get_api_url("translate"),
162 json=params,
163 )
164 payload = response.json()
166 result: DownloadMultipleTranslations = {}
167 for index, text in enumerate(texts):
168 result[text] = [
169 {
170 "text": payload["translations"][index]["text"],
171 "quality": self.max_score,
172 "service": self.name,
173 "source": text,
174 }
175 ]
176 return result
178 def format_replacement(
179 self, h_start: int, h_end: int, h_text: str, h_kind: Unit | None
180 ) -> str:
181 """Generate a single replacement."""
182 return f'<x id="{h_start}"></x>'
184 def is_glossary_supported(self, source_language: str, target_language: str) -> bool:
185 cache_key = self.get_cache_key("glossary_languages")
186 languages_cache = cache.get(cache_key)
187 if languages_cache is not None:
188 # hiredis-py 3 makes list from set
189 languages = set(languages_cache)
190 else:
191 response = self.request("get", self.get_api_url("glossary-language-pairs"))
192 languages = {
193 (support["source_lang"].upper(), support["target_lang"].upper())
194 for support in response.json()["supported_languages"]
195 }
197 cache.set(cache_key, languages, 24 * 3600)
199 source_language = source_language.split("-", 1)[0]
200 target_language = target_language.split("-", 1)[0]
201 return (source_language, target_language) in languages
203 def list_glossaries(self) -> dict[str, str]:
204 response = self.request("get", self.get_api_url("glossaries"))
205 return {
206 glossary["name"]: glossary["glossary_id"]
207 for glossary in response.json()["glossaries"]
208 }
210 def delete_oldest_glossary(self) -> None:
211 response = self.request("get", self.get_api_url("glossaries"))
212 glossaries = sorted(
213 response.json()["glossaries"],
214 key=lambda glossary: isoparse(glossary["creation_time"]),
215 )
216 if glossaries:
217 self.delete_glossary(glossaries[0]["glossary_id"])
219 def delete_glossary(self, glossary_id: str) -> None:
220 """
221 Delete glossary from service.
223 :param glossary_id: ID of the glossary to delete
224 :raises GlossaryDoesNotExistError: If the glossary does not exist
225 """
226 try:
227 self.request("delete", self.get_api_url("glossaries", glossary_id))
228 except HTTPError as error:
229 if error.response.status_code in {400, 404}:
230 raise GlossaryDoesNotExistError from error
232 def create_glossary(
233 self, source_language: str, target_language: str, name: str, tsv: str
234 ) -> None:
235 # Deepl gracefully handles glossaries with duplicate name by updating the existing one
236 self.request(
237 "post",
238 self.get_api_url("glossaries"),
239 json={
240 "name": name,
241 "source_lang": source_language.split("-", 1)[0],
242 "target_lang": target_language.split("-", 1)[0],
243 "entries": tsv,
244 "entries_format": "tsv",
245 },
246 )
248 def get_glossary_count_limit(self) -> int:
249 # Free tier has lower limit on glossaries
250 if self.api_base_url.startswith("https://api-free.deepl.com/v2"):
251 return 1
252 return super().get_glossary_count_limit()