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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7from typing import TYPE_CHECKING, ClassVar 

8 

9from dateutil.parser import isoparse 

10from django.core.cache import cache 

11from requests.exceptions import HTTPError, RequestException 

12 

13from .base import ( 

14 BatchMachineTranslation, 

15 GlossaryDoesNotExistError, 

16 GlossaryMachineTranslationMixin, 

17 XMLMachineTranslationMixin, 

18) 

19from .forms import DeepLMachineryForm 

20 

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 

23 

24 from weblate.auth.models import User 

25 from weblate.lang.models import Language 

26 from weblate.trans.models import Unit 

27 

28 from .base import ( 

29 DownloadMultipleTranslations, 

30 ) 

31 

32 

33class DeepLTranslation( 

34 XMLMachineTranslationMixin, GlossaryMachineTranslationMixin, BatchMachineTranslation 

35): 

36 """DeepL (Linguee) machine translation support.""" 

37 

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 

52 

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 

61 

62 def map_language_code(self, code): 

63 """Convert language to service specific code.""" 

64 return super().map_language_code(code).replace("_", "-").upper() 

65 

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] 

74 

75 def get_headers(self) -> dict[str, str]: 

76 return {"Authorization": f"DeepL-Auth-Key {self.settings['key']}"} 

77 

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 

89 

90 if isinstance(exc, HTTPError) and exc.response.status_code == 456: 

91 return "Quota exceeded. The character limit has been reached." 

92 

93 return super().get_error_message(exc) 

94 

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"} 

105 

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") 

113 

114 return ( 

115 (source, target) 

116 for source in source_languages 

117 for target in target_languages 

118 ) 

119 

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 

123 

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] 

135 

136 glossary_id = self.get_glossary_id(source_language, target_language, unit) 

137 

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" 

158 

159 response = self.request( 

160 "post", 

161 self.get_api_url("translate"), 

162 json=params, 

163 ) 

164 payload = response.json() 

165 

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 

177 

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>' 

183 

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 } 

196 

197 cache.set(cache_key, languages, 24 * 3600) 

198 

199 source_language = source_language.split("-", 1)[0] 

200 target_language = target_language.split("-", 1)[0] 

201 return (source_language, target_language) in languages 

202 

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 } 

209 

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"]) 

218 

219 def delete_glossary(self, glossary_id: str) -> None: 

220 """ 

221 Delete glossary from service. 

222 

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 

231 

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 ) 

247 

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()