diff --git a/docs/admin/install.rst b/docs/admin/install.rst index afa0c220373b..d4b4e8d3253e 100644 --- a/docs/admin/install.rst +++ b/docs/admin/install.rst @@ -230,12 +230,16 @@ Django REST Framework - :ref:`mt-google-translate-api-v3` + * - + - `google-cloud-storage `_ + - Optional glossary support for :ref:`mt-google-translate-api-v3` + + * - ``ldap`` - `django-auth-ldap `_ - :ref:`ldap-auth` - * - ``mercurial`` - `mercurial `_ - :ref:`vcs-mercurial` diff --git a/docs/admin/machine.rst b/docs/admin/machine.rst index ed992a7764f9..9b8f816f50df 100644 --- a/docs/admin/machine.rst +++ b/docs/admin/machine.rst @@ -241,6 +241,8 @@ Google Cloud Translation Advanced +-----------------+---------------------------------------+----------------------------------------------------------------------------------------------------------+ | ``location`` | Google Translate location | Choose a Google Cloud Translation region that is used for the Google Cloud project or is closest to you. | +-----------------+---------------------------------------+----------------------------------------------------------------------------------------------------------+ + | ``bucket_name`` | Google Storage Bucket name | Enter the name of the Google Cloud Storage bucket that is used to store the Glossary files. | + +-----------------+---------------------------------------+----------------------------------------------------------------------------------------------------------+ Machine translation service provided by the Google Cloud services. @@ -258,6 +260,18 @@ In order to use this service, you first need to go through the following steps: .. _Enable the Cloud Translation.: https://cloud.google.com/translate/docs/ .. _Setup Authentication.: https://googleapis.dev/python/google-api-core/latest/auth.html + +Optionally, you can configure the service to use :ref:`glossary` by setting up a Bucket: + +1. `Create a Google Cloud bucket.`_ +2. `Set bucket location to "us-central1".`_ +3. `Grant 'Storage Admin' permission to the Service Account.`_ + +.. _Create a Google Cloud bucket.: https://cloud.google.com/storage/docs/creating-buckets +.. _Set bucket location to "us-central1".: https://cloud.google.com/translate/docs/migrate-to-v3#resources_projects_and_locations +.. _Grant 'Storage Admin' permission to the Service Account.: https://cloud.google.com/translate/docs/access-control + + .. seealso:: `Google translate documentation `_, diff --git a/docs/changes.rst b/docs/changes.rst index 23bf8dae9753..d55a1a9dee11 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,7 @@ Not yet released. **Improvements** +* :ref:`mt-google-translate-api-v3` now supports :ref:`glossary-mt` (optional). * A shortcut to duplicate a component is now available directly in the menu (:guilabel:`Manage` → :guilabel:`Duplicate Component`) * Included username when generating :ref:`credits`. * :ref:`bulk-edit` shows a preview of matched strings. diff --git a/docs/user/glossary.rst b/docs/user/glossary.rst index a4494597ce2d..78301f7f9a04 100644 --- a/docs/user/glossary.rst +++ b/docs/user/glossary.rst @@ -129,6 +129,7 @@ Following automatic suggestion services utilize glossaries during the translatio * :ref:`mt-microsoft-translator` * :ref:`mt-modernmt` * :ref:`mt-aws` +* :ref:`mt-google-translate-api-v3` The glossary is processed before exposed to the service: diff --git a/pyproject.toml b/pyproject.toml index 3636d9e3ddce..72efdb4ec24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -167,7 +167,8 @@ gerrit = [ "git-review>=2.4.0,<2.5.0" ] google = [ - "google-cloud-translate>=3.13.0,<4.0" + "google-cloud-translate>=3.13.0,<4.0", + "google-cloud-storage>=2.18.2,<3.0" ] ldap = [ "django-auth-ldap>=4.6.0,<6.0.0" diff --git a/scripts/show-extras b/scripts/show-extras index cb89613543ae..f592edf79476 100755 --- a/scripts/show-extras +++ b/scripts/show-extras @@ -26,11 +26,13 @@ with open("pyproject.toml", "rb") as handle: for section, data in toml_dict["project"]["optional-dependencies"].items(): if section == "all": continue - dependency = re.split("[;<>=[]", data[0])[0].strip() - print( - f""" - * - ``{section}`` - - `{dependency} `_ - - -""" - ) + for index, dependency in enumerate(data): + dependency = re.split("[;<>=[]", dependency)[0].strip() + section = f"``{section}``" if index == 0 else "" + print( + f""" + * - {section} + - `{dependency} `_ + - + """ + ) diff --git a/uv.lock b/uv.lock index 58627a56e037..11e381082de0 100644 --- a/uv.lock +++ b/uv.lock @@ -1231,6 +1231,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/0f/2e2061e3fbcb9d535d5da3f58cc8de4947df1786fe6a1355960feb05a681/google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61", size = 29233 }, ] +[[package]] +name = "google-cloud-storage" +version = "2.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/b7/1554cdeb55d9626a4b8720746cba8119af35527b12e1780164f9ba0f659a/google_cloud_storage-2.18.2.tar.gz", hash = "sha256:aaf7acd70cdad9f274d29332673fcab98708d0e1f4dceb5a5356aaef06af4d99", size = 5532864 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/da/95db7bd4f0bd1644378ac1702c565c0210b004754d925a74f526a710c087/google_cloud_storage-2.18.2-py2.py3-none-any.whl", hash = "sha256:97a4d45c368b7d401ed48c4fdfe86e1e1cb96401c9e199e419d289e2c0370166", size = 130466 }, +] + [[package]] name = "google-cloud-translate" version = "3.17.0" @@ -1248,6 +1265,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/8e/274bf1ff50f7c372c77db5a3ee903acc1cfb2a6a0a15e90592ca9b2b6bf8/google_cloud_translate-3.17.0-py2.py3-none-any.whl", hash = "sha256:de6f6c11afd625c880adeba0da434e0e83863903fada20214f93c1e3dee5e912", size = 183053 }, ] +[[package]] +name = "google-crc32c" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/72/c3298da1a3773102359c5a78f20dae8925f5ea876e37354415f68594a6fb/google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc", size = 14472 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/14/ab47972ac79b6e7b03c8be3a7ef44b530a60e69555668dbbf08fc5692a98/google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4", size = 30267 }, + { url = "https://files.pythonhosted.org/packages/54/7d/738cb0d25ee55629e7d07da686decf03864a366e5e863091a97b7bd2b8aa/google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8", size = 30112 }, + { url = "https://files.pythonhosted.org/packages/3e/6d/33ca50cbdeec09c31bb5dac277c90994edee975662a4c890bda7ffac90ef/google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d", size = 32861 }, + { url = "https://files.pythonhosted.org/packages/67/1e/4870896fc81ec77b1b5ebae7fdd680d5a4d40e19a4b6d724032f996ca77a/google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f", size = 32490 }, + { url = "https://files.pythonhosted.org/packages/00/9c/f5f5af3ddaa7a639d915f8f58b09bbb8d1db90ecd0459b62cd430eb9a4b6/google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3", size = 33446 }, + { url = "https://files.pythonhosted.org/packages/cf/41/65a91657d6a8123c6c12f9aac72127b6ac76dda9e2ba1834026a842eb77c/google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d", size = 30268 }, + { url = "https://files.pythonhosted.org/packages/59/d0/ee743a267c7d5c4bb8bd865f7d4c039505f1c8a4b439df047fdc17be9769/google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b", size = 30113 }, + { url = "https://files.pythonhosted.org/packages/25/53/e5e449c368dd26ade5fb2bb209e046d4309ed0623be65b13f0ce026cb520/google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00", size = 32995 }, + { url = "https://files.pythonhosted.org/packages/52/12/9bf6042d5b0ac8c25afed562fb78e51b0641474097e4139e858b45de40a5/google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3", size = 32614 }, + { url = "https://files.pythonhosted.org/packages/76/29/fc20f5ec36eac1eea0d0b2de4118c774c5f59c513f2a8630d4db6991f3e0/google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760", size = 33445 }, +] + +[[package]] +name = "google-resumable-media" +version = "2.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/5a/0efdc02665dca14e0837b62c8a1a93132c264bd02054a15abb2218afe0ae/google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0", size = 2163099 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa", size = 81251 }, +] + [[package]] name = "googleapis-common-protos" version = "1.65.0" @@ -3513,6 +3560,7 @@ all = [ { name = "django-auth-ldap" }, { name = "django-zxcvbn-password" }, { name = "git-review" }, + { name = "google-cloud-storage" }, { name = "google-cloud-translate" }, { name = "mercurial" }, { name = "openai" }, @@ -3530,6 +3578,7 @@ gerrit = [ { name = "git-review" }, ] google = [ + { name = "google-cloud-storage" }, { name = "google-cloud-translate" }, ] ldap = [ @@ -3668,6 +3717,7 @@ requires-dist = [ { name = "fluent-syntax", specifier = ">=0.18.1,<0.20" }, { name = "git-review", marker = "extra == 'gerrit'", specifier = ">=2.4.0,<2.5.0" }, { name = "gitpython", specifier = ">=3.1.0,<3.2" }, + { name = "google-cloud-storage", marker = "extra == 'google'", specifier = ">=2.18.2,<3.0" }, { name = "google-cloud-translate", marker = "extra == 'google'", specifier = ">=3.13.0,<4.0" }, { name = "gunicorn", marker = "extra == 'wsgi'", specifier = "==23.0.0" }, { name = "hiredis", specifier = ">=2.2.1,<3.1" }, diff --git a/weblate/machinery/base.py b/weblate/machinery/base.py index 527f961a603a..0db06c9e067f 100644 --- a/weblate/machinery/base.py +++ b/weblate/machinery/base.py @@ -77,6 +77,7 @@ class SettingsDict(TypedDict, total=False): persona: str style: str custom_model: str + bucket_name: str class TranslationResultDict(TypedDict): diff --git a/weblate/machinery/forms.py b/weblate/machinery/forms.py index bd0d1d0563e4..a7027b523311 100644 --- a/weblate/machinery/forms.py +++ b/weblate/machinery/forms.py @@ -215,6 +215,16 @@ class GoogleV3MachineryForm(BaseMachineryForm): ) ), ) + bucket_name = forms.CharField( + label=pgettext_lazy( + "Automatic suggestion service configuration", "Google Storage Bucket name" + ), + help_text=pgettext_lazy( + "Google Cloud Translation configuration", + "Enter the name of the Google Cloud Storage bucket that is used to store the Glossary files.", + ), + required=False, + ) def clean_credentials(self): try: diff --git a/weblate/machinery/googlev3.py b/weblate/machinery/googlev3.py index afe2fb9d5de3..13dd5cc88970 100644 --- a/weblate/machinery/googlev3.py +++ b/weblate/machinery/googlev3.py @@ -5,13 +5,25 @@ from __future__ import annotations import json +import operator from typing import TYPE_CHECKING from django.utils.functional import cached_property -from google.cloud.translate import TranslationServiceClient +from google.cloud import storage +from google.cloud.translate_v3 import ( + GcsSource, + Glossary, + GlossaryInputConfig, + TranslateTextGlossaryConfig, + TranslationServiceClient, +) from google.oauth2 import service_account -from .base import DownloadTranslations, XMLMachineTranslationMixin +from .base import ( + DownloadTranslations, + GlossaryMachineTranslationMixin, + XMLMachineTranslationMixin, +) from .forms import GoogleV3MachineryForm from .google import GoogleBaseTranslation @@ -19,13 +31,23 @@ from weblate.trans.models import Unit -class GoogleV3Translation(XMLMachineTranslationMixin, GoogleBaseTranslation): +class GoogleV3Translation( + XMLMachineTranslationMixin, GoogleBaseTranslation, GlossaryMachineTranslationMixin +): """Google Translate API v3 machine translation support.""" name = "Google Cloud Translation Advanced" max_score = 90 settings_form = GoogleV3MachineryForm + # estimation, actual limit is 10.4 million (10,485,760) UTF-8 bytes + glossary_count_limit = 1000 + + # Identifier must contain only lowercase letters, digits, or hyphens. + glossary_name_format = ( + "weblate__{project}__{source_language}__{target_language}__{checksum}" + ) + @classmethod def get_identifier(cls) -> str: return "google-translate-api-v3" @@ -44,6 +66,17 @@ def client(self): credentials=credentials, client_options={"api_endpoint": api_endpoint} ) + @cached_property + def storage_client(self): + credentials = service_account.Credentials.from_service_account_info( + json.loads(self.settings["credentials"]) + ) + return storage.Client(credentials=credentials) + + @cached_property + def storage_bucket(self): + return self.storage_client.get_bucket(self.settings["bucket_name"]) + @cached_property def parent(self) -> str: project = self.settings["project"] @@ -72,10 +105,21 @@ def download_translations( "source_language_code": source, "mime_type": "text/html", } + glossary_path: str | None = None + if self.settings.get("bucket_name"): + glossary_path = self.get_glossary_id(source, language, unit) + request["glossary_config"] = TranslateTextGlossaryConfig( + glossary=glossary_path + ) + response = self.client.translate_text(request) + response_translations = ( + response.glossary_translations if glossary_path else response.translations + ) + yield { - "text": response.translations[0].translated_text, + "text": response_translations[0].translated_text, "quality": self.max_score, "service": self.name, "source": text, @@ -95,3 +139,62 @@ def cleanup_text(self, text, unit): replacements[replacement] = "\n" return text.replace("\n", replacement), replacements + + def list_glossaries(self) -> dict[str, str]: + """Return dictionary with the name/id of the glossary as the key and value.""" + return { + glossary.display_name: glossary.display_name + for glossary in self.client.list_glossaries(parent=self.parent) + } + + def create_glossary( + self, source_language: str, target_language: str, name: str, tsv: str + ) -> None: + """ + Create glossary in the service. + + - Uploads the TSV file to gcs bucket + - Creates the glossary in the service + """ + # upload tsv to storage bucket + glossary_bucket_file = self.storage_bucket.blob(f"{name}.tsv") + glossary_bucket_file.upload_from_string( + tsv, content_type="text/tab-separated-values" + ) + # create glossary + bucket_name = self.settings["bucket_name"] + gcs_source = GcsSource(input_uri=f"gs://{bucket_name}/{name}.tsv") + input_config = GlossaryInputConfig(gcs_source=gcs_source) + + glossary = Glossary( + name=self.get_glossary_resource_path(name), + language_pair=Glossary.LanguageCodePair( + source_language_code=source_language, + target_language_code=target_language, + ), + input_config=input_config, + ) + self.client.create_glossary(parent=self.parent, glossary=glossary) + + def delete_glossary(self, glossary_name: str) -> None: + """Delete the glossary in service and storage bucket.""" + self.client.delete_glossary(name=self.get_glossary_resource_path(glossary_name)) + + # delete tsv from storage bucket + glossary_bucket_file = self.storage_bucket.blob(f"{glossary_name}.tsv") + glossary_bucket_file.delete() + + def delete_oldest_glossary(self) -> None: + """Delete the oldest glossary if any.""" + glossaries = sorted( + self.client.list_glossaries(parent=self.parent), + key=operator.attrgetter("submit_time"), + ) + if glossaries: + self.delete_glossary(glossaries[0].display_name) + + def get_glossary_resource_path(self, glossary_name: str): + """Return the resource path used by the Translation API.""" + return self.client.glossary_path( + self.settings["project"], self.settings["location"], glossary_name + ) diff --git a/weblate/machinery/tests.py b/weblate/machinery/tests.py index 5ccde22378d0..c23d1a16eba3 100644 --- a/weblate/machinery/tests.py +++ b/weblate/machinery/tests.py @@ -7,10 +7,11 @@ import json import re from copy import copy +from datetime import UTC, datetime from functools import partial from io import StringIO from typing import TYPE_CHECKING, NoReturn -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, call, patch import httpx import responses @@ -787,7 +788,7 @@ def mock_empty(self) -> NoReturn: def mock_error(self) -> NoReturn: self.skipTest("Not tested") - def mock_response(self) -> None: + def mock_languages(self) -> None: # Mock get supported languages patcher = patch.object( TranslationServiceClient, @@ -807,6 +808,9 @@ def mock_response(self) -> None: patcher.start() self.addCleanup(patcher.stop) + def mock_response(self) -> None: + self.mock_languages() + # Mock translate patcher = patch.object( TranslationServiceClient, @@ -853,6 +857,149 @@ def test_replacements(self) -> None: unit.source, machine_translation.uncleanup_text(replacements, replaced) ) + # set glossary_count_limit to 1 to also trigger delete_oldest_glossary + @patch("weblate.glossary.models.get_glossary_tsv", new=lambda _: "foo\tbar") + @patch("weblate.machinery.googlev3.GoogleV3Translation.glossary_count_limit", new=1) + def test_glossary(self) -> None: + self.mock_languages() + self.mock_glossary_responses() + self.CONFIGURATION["bucket_name"] = "test-bucket" + + self.assert_translate(self.SUPPORTED, self.SOURCE_TRANSLATED, self.EXPECTED_LEN) + + @patch("weblate.glossary.models.get_glossary_tsv", new=lambda _: "foo\tbar") + @patch("weblate.machinery.googlev3.GoogleV3Translation.glossary_count_limit", new=1) + def test_glossary_with_calls_check(self) -> None: + self.mock_languages() + self.mock_glossary_responses() + self.CONFIGURATION["bucket_name"] = "test-bucket" + + with patch( + "weblate.machinery.googlev3.GoogleV3Translation.delete_glossary" + ) as delete_glossary_method: + self.assert_translate( + self.SUPPORTED, self.SOURCE_TRANSLATED, self.EXPECTED_LEN + ) + delete_glossary_method.assert_has_calls( + [ + call("weblate__1__en__cs__a85e314d2f7614eb"), + call("weblate__1__en__it__2d9a814c5f6321a8"), + ] + ) + + def mock_glossary_responses(self) -> None: + """ + Mock the responses for Google Cloud Translate V3 API. + + Patches list_glossaries, create_glossary, delete_glossary, translate_text + and also the storage client. + """ + from google.cloud.translate_v3 import Glossary + from google.oauth2 import service_account + + def _glossary(name: str, submit_time: datetime) -> Glossary: + """Return a mock Glossary object with given name and submit time.""" + return Glossary( + display_name=name, + submit_time=submit_time, + ) + + # Mock list glossaries + list_glossaries_patcher = patch.object( + TranslationServiceClient, + "list_glossaries", + Mock( + side_effect=[ + # with stale glossary and another glossary + [ + _glossary( + "weblate__1__en__cs__a85e314d2f7614eb", + datetime(2024, 9, 1, tzinfo=UTC), + ), + _glossary( + "weblate__1__en__it__2d9a814c5f6321a8", + datetime(2024, 9, 1, tzinfo=UTC), + ), + ], + # the stale glossary has been deleted + [ + _glossary( + "weblate__1__en__it__2d9a814c5f6321a8", + datetime(2024, 9, 1, tzinfo=UTC), + ) + ], + # new glossary + [ + _glossary( + "weblate__1__en__cs__9e250d830c11d70f", + datetime(2024, 10, 1, tzinfo=UTC), + ) + ], + ] + ), + ) + list_glossaries_patcher.start() + self.addCleanup(list_glossaries_patcher.stop) + + # Mock create glossary + create_glossary_patcher = patch.object( + TranslationServiceClient, "create_glossary", Mock() + ) + create_glossary_patcher.start() + self.addCleanup(create_glossary_patcher.stop) + + # Mock delete glossary + delete_glossary_patcher = patch.object( + TranslationServiceClient, "delete_glossary", Mock() + ) + delete_glossary_patcher.start() + self.addCleanup(delete_glossary_patcher.stop) + + # Mock translate with glossary + translate_patcher = patch.object( + TranslationServiceClient, + "translate_text", + Mock( + return_value=TranslateTextResponse( + { + "translations": [{"translated_text": "Ahoj"}], + "glossary_translations": [{"translated_text": "Ahoj"}], + } + ), + ), + ) + translate_patcher.start() + self.addCleanup(translate_patcher.stop) + + get_credentials_patcher = patch.object( + service_account.Credentials, + "from_service_account_info", + return_value=MagicMock(), + ) + get_credentials_patcher.start() + self.addCleanup(get_credentials_patcher.stop) + + class MockBlob(MagicMock): + def upload_from_string(self, *args, **kwargs): + """Mock google.cloud.storage.Blob.upload_from_string.""" + + def delete(self, *args, **kwargs): + """Mock google.cloud.storage.Blob.delete.""" + + class MockBucket(MagicMock): + def blob(self, *args, **kwargs): + """Mock google.cloud.storage.Bucket.blob.""" + return MockBlob() + + class MockStorageClient(MagicMock): + def get_bucket(self, *args, **kwargs): + """google.cloud.storage.Client.get_bucket.""" + return MockBucket() + + patcher = patch("google.cloud.storage.Client", new=MockStorageClient) + patcher.start() + self.addCleanup(patcher.stop) + class TMServerTranslationTest(BaseMachineTranslationTest): MACHINE_CLS = TMServerTranslation