Coverage for app/venv/lib/python3.14/site-packages/weblate/machinery/microsoft.py: 26%

91 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 datetime import timedelta 

8from typing import TYPE_CHECKING, ClassVar 

9 

10from django.utils import timezone 

11 

12from weblate.glossary.models import get_glossary_terms 

13 

14from .base import ( 

15 MachineTranslation, 

16 MachineTranslationError, 

17 XMLMachineTranslationMixin, 

18) 

19from .forms import MicrosoftMachineryForm 

20 

21if TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true

22 from datetime import datetime 

23 

24 from weblate.trans.models import Unit 

25 

26 from .base import ( 

27 DownloadTranslations, 

28 SettingsDict, 

29 ) 

30 

31TOKEN_URL = "https://{0}{1}/sts/v1.0/issueToken?Subscription-Key={2}" # noqa: S105 

32TOKEN_EXPIRY = timedelta(minutes=9) 

33 

34 

35class MicrosoftCognitiveTranslation(XMLMachineTranslationMixin, MachineTranslation): 

36 """Microsoft Cognitive Services Translator API support.""" 

37 

38 name = "Azure AI Translator" 

39 max_score = 90 

40 settings_form = MicrosoftMachineryForm 

41 glossary_support = True 

42 

43 language_map: ClassVar[dict[str, str]] = { 

44 "zh_TW": "zh-Hant", 

45 "zh_CN": "zh-Hans", 

46 "yue_Hant": "yue", 

47 "tlh": "tlh-Latn", 

48 "tlh_Qaak": "tlh-Piqd", 

49 "lg": "lug", 

50 "mn": "mn-Cyrl", 

51 "pt_BR": "pt", 

52 "rn": "run", 

53 "sr": "sr-Cyrl", 

54 } 

55 

56 @classmethod 

57 def get_identifier(cls) -> str: 

58 return "microsoft-translator" 

59 

60 def __init__(self, settings: SettingsDict) -> None: 

61 """Check configuration.""" 

62 super().__init__(settings) 

63 self._access_token: str | None = None 

64 self._token_expiry: datetime | None = None 

65 

66 # check settings for Microsoft region prefix 

67 region = "" if not self.settings["region"] else f"{self.settings['region']}." 

68 

69 self._cognitive_token_url = TOKEN_URL.format( 

70 region, 

71 self.settings["endpoint_url"], 

72 self.settings["key"], 

73 ) 

74 

75 def get_url(self, suffix) -> str: 

76 return f"https://{self.settings['base_url']}/{suffix}" 

77 

78 def is_token_expired(self): 

79 """Check whether token is about to expire.""" 

80 return self._token_expiry is None or self._token_expiry <= timezone.now() 

81 

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

83 """Add authentication headers to request.""" 

84 return {"Authorization": f"Bearer {self.access_token}"} 

85 

86 @property 

87 def access_token(self): 

88 """Obtain and caches access token.""" 

89 if self._access_token is None or self.is_token_expired(): 

90 self._access_token = self.request( 

91 "post", self._cognitive_token_url, skip_auth=True 

92 ).text 

93 self._token_expiry = timezone.now() + TOKEN_EXPIRY 

94 

95 return self._access_token 

96 

97 def map_language_code(self, code): 

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

99 code = super().map_language_code(code).replace("_", "-") 

100 if "-" in code: 

101 lang, country = code.split("-", 1) 

102 if len(country) == 2: 

103 country = country.lower() 

104 code = f"{lang}-{country}" 

105 return code 

106 

107 def check_failure(self, response) -> None: 

108 # Microsoft tends to use utf-8-sig instead of plain utf-8 

109 response.encoding = response.apparent_encoding 

110 super().check_failure(response) 

111 if ( 

112 response.url.startswith("https://api.cognitive.microsofttranslator.com/") 

113 and response.status_code == 200 

114 ): 

115 payload = response.json() 

116 

117 # We should get an object, string usually means an error 

118 if isinstance(payload, str): 

119 raise MachineTranslationError(payload) 

120 

121 def download_languages(self): 

122 """ 

123 Download list of supported languages from a service. 

124 

125 Example of the response: 

126 

127 ['af', 'ar', 'bs-Latn', 'bg', 'ca', 'zh-CHS', 'zh-CHT', 'yue', 'hr', 'cs', 'da', 

128 'nl', 'en', 'et', 'fj', 'fil', 'fi', 'fr', 'de', 'el', 'ht', 'he', 'hi', 'mww', 

129 'h', 'id', 'it', 'ja', 'sw', 'tlh', 'tlh-Qaak', 'ko', 'lv', 'lt', 'mg', 'ms', 

130 'mt', 'yua', 'no', 'otq', 'fa', 'pl', 'pt', 'ro', 'r', 'sm', 'sr-Cyrl', 

131 'sr-Latn', 'sk', 'sl', 'es', 'sv', 'ty', 'th', 'to', 'tr', 'uk', 'ur', 'vi', 

132 'cy'] 

133 """ 

134 response = self.request( 

135 "get", self.get_url("languages"), params={"api-version": "3.0"} 

136 ) 

137 # Microsoft tends to use utf-8-sig instead of plain utf-8 

138 response.encoding = response.apparent_encoding 

139 payload = response.json() 

140 

141 return payload["translation"].keys() 

142 

143 def download_translations( 

144 self, 

145 source_language, 

146 target_language, 

147 text: str, 

148 unit, 

149 user, 

150 threshold: int = 75, 

151 ) -> DownloadTranslations: 

152 """Download list of possible translations from a service.""" 

153 args = { 

154 "api-version": "3.0", 

155 "from": source_language, 

156 "to": target_language, 

157 "category": self.settings.get("category", "general"), 

158 "textType": "html", 

159 } 

160 response = self.request( 

161 "post", self.get_url("translate"), params=args, json=[{"Text": text[:5000]}] 

162 ) 

163 # Microsoft tends to use utf-8-sig instead of plain utf-8 

164 response.encoding = "utf-8-sig" 

165 payload = response.json() 

166 yield { 

167 "text": payload[0]["translations"][0]["text"], 

168 "quality": self.max_score, 

169 "service": self.name, 

170 "source": text, 

171 } 

172 

173 def format_replacement( 

174 self, h_start: int, h_end: int, h_text: str, h_kind: Unit | None 

175 ): 

176 """Generate a single replacement.""" 

177 if h_kind is None: 

178 return f'<span class="notranslate" id="{h_start}">{self.escape_text(h_text)}</span>' 

179 # Glossary 

180 flags = h_kind.all_flags 

181 if "forbidden" in flags: 

182 return h_text 

183 if "read-only" in flags: 

184 # Use terminology format 

185 return self.format_replacement(h_start, h_end, h_text, None) 

186 return f'<mstrans:dictionary translation="{self.escape_text(h_kind.target)}">{self.escape_text(h_text)}</mstrans:dictionary>' 

187 

188 def get_highlights(self, text, unit): 

189 result = list(super().get_highlights(text, unit)) 

190 

191 for term in get_glossary_terms(unit, include_variants=False): 

192 for start, end in term.glossary_positions: 

193 glossary_highlight = (start, end, text[start:end], term) 

194 handled = False 

195 for i, (h_start, _h_end, _h_text, _h_kind) in enumerate(result): 

196 if start < h_start: 

197 if end > h_start: 

198 # Skip as overlaps 

199 break 

200 # Insert before 

201 result.insert(i, glossary_highlight) 

202 handled = True 

203 break 

204 if not handled and (not result or result[-1][1] < start): 

205 result.append(glossary_highlight) 

206 

207 yield from result