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
« 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 datetime import timedelta
8from typing import TYPE_CHECKING, ClassVar
10from django.utils import timezone
12from weblate.glossary.models import get_glossary_terms
14from .base import (
15 MachineTranslation,
16 MachineTranslationError,
17 XMLMachineTranslationMixin,
18)
19from .forms import MicrosoftMachineryForm
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
24 from weblate.trans.models import Unit
26 from .base import (
27 DownloadTranslations,
28 SettingsDict,
29 )
31TOKEN_URL = "https://{0}{1}/sts/v1.0/issueToken?Subscription-Key={2}" # noqa: S105
32TOKEN_EXPIRY = timedelta(minutes=9)
35class MicrosoftCognitiveTranslation(XMLMachineTranslationMixin, MachineTranslation):
36 """Microsoft Cognitive Services Translator API support."""
38 name = "Azure AI Translator"
39 max_score = 90
40 settings_form = MicrosoftMachineryForm
41 glossary_support = True
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 }
56 @classmethod
57 def get_identifier(cls) -> str:
58 return "microsoft-translator"
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
66 # check settings for Microsoft region prefix
67 region = "" if not self.settings["region"] else f"{self.settings['region']}."
69 self._cognitive_token_url = TOKEN_URL.format(
70 region,
71 self.settings["endpoint_url"],
72 self.settings["key"],
73 )
75 def get_url(self, suffix) -> str:
76 return f"https://{self.settings['base_url']}/{suffix}"
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()
82 def get_headers(self) -> dict[str, str]:
83 """Add authentication headers to request."""
84 return {"Authorization": f"Bearer {self.access_token}"}
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
95 return self._access_token
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
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()
117 # We should get an object, string usually means an error
118 if isinstance(payload, str):
119 raise MachineTranslationError(payload)
121 def download_languages(self):
122 """
123 Download list of supported languages from a service.
125 Example of the response:
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()
141 return payload["translation"].keys()
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 }
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>'
188 def get_highlights(self, text, unit):
189 result = list(super().get_highlights(text, unit))
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)
207 yield from result