Coverage for app/venv/lib/python3.14/site-packages/weblate/machinery/googlev3.py: 39%
93 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
7import json
8import operator
9from typing import TYPE_CHECKING
11from django.utils.functional import cached_property
12from google.api_core.exceptions import AlreadyExists, NotFound
13from google.cloud import storage # type: ignore[attr-defined]
14from google.cloud.translate_v3 import (
15 GcsSource,
16 Glossary,
17 GlossaryInputConfig,
18 TranslateTextGlossaryConfig,
19 TranslationServiceClient,
20)
21from google.oauth2 import service_account
23from .base import (
24 GlossaryAlreadyExistsError,
25 GlossaryDoesNotExistError,
26 GlossaryMachineTranslationMixin,
27 XMLMachineTranslationMixin,
28)
29from .forms import GoogleV3MachineryForm
30from .google import GoogleBaseTranslation
32if TYPE_CHECKING: 32 ↛ 33line 32 didn't jump to line 33 because the condition on line 32 was never true
33 from weblate.trans.models import Unit
35 from .base import (
36 DownloadTranslations,
37 )
40class GoogleV3Translation(
41 XMLMachineTranslationMixin, GoogleBaseTranslation, GlossaryMachineTranslationMixin
42):
43 """Google Translate API v3 machine translation support."""
45 name = "Google Cloud Translation Advanced"
46 max_score = 90
47 settings_form = GoogleV3MachineryForm
49 # estimation, actual limit is 10.4 million (10,485,760) UTF-8 bytes
50 glossary_count_limit = 1000
52 # Identifier must contain only lowercase letters, digits, or hyphens.
53 glossary_name_format = (
54 "weblate__{project}__{source_language}__{target_language}__{checksum}"
55 )
57 @classmethod
58 def get_identifier(cls) -> str:
59 return "google-translate-api-v3"
61 @cached_property
62 def client(self):
63 credentials = service_account.Credentials.from_service_account_info(
64 json.loads(self.settings["credentials"])
65 )
66 api_endpoint = "translate.googleapis.com"
67 if self.settings["location"].startswith("europe-"):
68 api_endpoint = "translate-eu.googleapis.com"
69 elif self.settings["location"].startswith("us-"):
70 api_endpoint = "translate-us.googleapis.com"
71 return TranslationServiceClient(
72 credentials=credentials, client_options={"api_endpoint": api_endpoint}
73 )
75 @cached_property
76 def storage_client(self):
77 credentials = service_account.Credentials.from_service_account_info(
78 json.loads(self.settings["credentials"])
79 )
80 return storage.Client(credentials=credentials)
82 @cached_property
83 def storage_bucket(self):
84 return self.storage_client.get_bucket(self.settings["bucket_name"])
86 @cached_property
87 def parent(self) -> str:
88 project = self.settings["project"]
89 location = self.settings["location"]
90 return f"projects/{project}/locations/{location}"
92 def download_languages(self):
93 """List of supported languages."""
94 response = self.client.get_supported_languages(request={"parent": self.parent})
95 return [language.language_code for language in response.languages]
97 def download_translations(
98 self,
99 source_language,
100 target_language,
101 text: str,
102 unit,
103 user,
104 threshold: int = 75,
105 ) -> DownloadTranslations:
106 """Download list of possible translations from a service."""
107 request = {
108 "parent": self.parent,
109 "contents": [text],
110 "target_language_code": target_language,
111 "source_language_code": source_language,
112 "mime_type": "text/html",
113 }
114 glossary_path: str | None = None
115 if self.settings.get("bucket_name"):
116 glossary_path = self.get_glossary_id(source_language, target_language, unit)
117 request["glossary_config"] = TranslateTextGlossaryConfig(
118 glossary=glossary_path
119 )
121 response = self.client.translate_text(request)
123 response_translations = (
124 response.glossary_translations if glossary_path else response.translations
125 )
127 yield {
128 "text": response_translations[0].translated_text,
129 "quality": self.max_score,
130 "service": self.name,
131 "source": text,
132 }
134 def format_replacement(
135 self, h_start: int, h_end: int, h_text: str, h_kind: Unit | None
136 ) -> str:
137 """Generate a single replacement."""
138 return f'<span translate="no" id="{h_start}">{self.escape_text(h_text)}</span>'
140 def cleanup_text(self, text, unit):
141 text, replacements = super().cleanup_text(text, unit)
143 # Sanitize newlines
144 replacement = '<br translate="no">'
145 replacements[replacement] = "\n"
147 return text.replace("\n", replacement), replacements
149 def list_glossaries(self) -> dict[str, str]:
150 """Return dictionary with the name/id of the glossary as the key and value."""
151 return {
152 glossary.display_name: glossary.display_name
153 for glossary in self.client.list_glossaries(parent=self.parent)
154 }
156 def create_glossary(
157 self, source_language: str, target_language: str, name: str, tsv: str
158 ) -> None:
159 """
160 Create glossary in the service.
162 - Uploads the TSV file to gcs bucket
163 - Creates the glossary in the service
164 """
165 # upload tsv to storage bucket
166 glossary_bucket_file = self.storage_bucket.blob(f"{name}.tsv")
167 glossary_bucket_file.upload_from_string(
168 tsv, content_type="text/tab-separated-values"
169 )
170 # create glossary
171 bucket_name = self.settings["bucket_name"]
172 gcs_source = GcsSource(input_uri=f"gs://{bucket_name}/{name}.tsv")
173 input_config = GlossaryInputConfig(gcs_source=gcs_source)
175 glossary = Glossary(
176 name=self.get_glossary_resource_path(name),
177 language_pair=Glossary.LanguageCodePair(
178 source_language_code=source_language,
179 target_language_code=target_language,
180 ),
181 input_config=input_config,
182 )
183 try:
184 self.client.create_glossary(parent=self.parent, glossary=glossary)
185 except AlreadyExists as error:
186 raise GlossaryAlreadyExistsError from error
188 def delete_glossary(self, glossary_name: str) -> None:
189 """Delete the glossary in service and storage bucket."""
190 try:
191 self.client.delete_glossary(
192 name=self.get_glossary_resource_path(glossary_name)
193 )
194 except NotFound as error:
195 raise GlossaryDoesNotExistError from error
196 finally:
197 try:
198 # delete tsv from storage bucket
199 glossary_bucket_file = self.storage_bucket.blob(f"{glossary_name}.tsv")
200 glossary_bucket_file.delete()
201 except NotFound:
202 pass
204 def delete_oldest_glossary(self) -> None:
205 """Delete the oldest glossary if any."""
206 glossaries = sorted(
207 self.client.list_glossaries(parent=self.parent),
208 key=operator.attrgetter("submit_time"),
209 )
210 if glossaries:
211 self.delete_glossary(glossaries[0].display_name)
213 def get_glossary_resource_path(self, glossary_name: str):
214 """Return the resource path used by the Translation API."""
215 return self.client.glossary_path(
216 self.settings["project"], self.settings["location"], glossary_name
217 )