Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add LibreTranslate machine translator, documentation and its test cases. #753

Closed
wants to merge 8 commits into from
19 changes: 19 additions & 0 deletions docs/how-to/integrations/machine-translation.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,25 @@ WAGTAILLOCALIZE_MACHINE_TRANSLATOR = {
}
```

## LibreTranslate

Website: [https://libretranslate.com/](https://libretranslate.com/)

!!! note

You will need a subscription to get an API key. Alternatively, you can host your own instance. See more details at [https://github.com/LibreTranslate/LibreTranslate](https://github.com/LibreTranslate/LibreTranslate).

```python
WAGTAILLOCALIZE_MACHINE_TRANSLATOR = {
"CLASS": "wagtail_localize.machine_translators.libretranslate.LibreTranslator",
"OPTIONS": {
"LIBRETRANSLATE_URL": "https://libretranslate.org", # or your self-hosted instance URL
# For self-hosted instances without API key setup, use a random string as the API key.
"API_KEY": "<Your LibreTranslate api key here>",
},
}
```

## Dummy

The dummy translator exists primarily for testing Wagtail Localize and it only reverses the strings that are passed to
Expand Down
50 changes: 50 additions & 0 deletions wagtail_localize/machine_translators/libretranslate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import json

import requests

from wagtail_localize.machine_translators.base import BaseMachineTranslator
from wagtail_localize.strings import StringValue


class LibreTranslator(BaseMachineTranslator):
"""
A machine translator that uses the LibreTranslate API.

API Documentation:
https://libretranslate.com/docs/
"""

display_name = "LibreTranslate"

def get_api_endpoint(self):
return self.options["LIBRETRANSLATE_URL"]

def language_code(self, code):
return code.split("-")[0]

def translate(self, source_locale, target_locale, strings):
translations = [item.data for item in list(strings)]
response = requests.post(
self.get_api_endpoint() + "/translate",
data=json.dumps(
{
"q": translations,
"source": self.language_code(source_locale.language_code),
"target": self.language_code(target_locale.language_code),
"api_key": self.options["API_KEY"],
}
),
headers={"Content-Type": "application/json"},
timeout=10,
)
response.raise_for_status()

return {
string: StringValue(translation)
for string, translation in zip(strings, response.json()["translatedText"])
}

def can_translate(self, source_locale, target_locale):
return self.language_code(source_locale.language_code) != self.language_code(
target_locale.language_code
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import json

from unittest import mock

from django.test import TestCase, override_settings
from wagtail.models import Locale

from wagtail_localize.machine_translators import get_machine_translator
from wagtail_localize.machine_translators.libretranslate import LibreTranslator
from wagtail_localize.strings import StringValue


LIBRETRANSLATE_SETTINGS_ENDPOINT = {
"CLASS": "wagtail_localize.machine_translators.libretranslate.LibreTranslator",
"OPTIONS": {
"LIBRETRANSLATE_URL": "https://libretranslate.org",
"API_KEY": "test-api-key",
},
}


class TestLibreTranslator(TestCase):
@override_settings(
WAGTAILLOCALIZE_MACHINE_TRANSLATOR=LIBRETRANSLATE_SETTINGS_ENDPOINT
)
def setUp(self):
self.english_locale = Locale.objects.get()
self.french_locale = Locale.objects.create(language_code="fr-fr")
self.translator = get_machine_translator()

def test_api_endpoint(self):
self.assertIsInstance(self.translator, LibreTranslator)
api_endpoint = self.translator.get_api_endpoint()
self.assertEqual(api_endpoint, "https://libretranslate.org")

def test_language_code(self):
self.assertEqual(
self.translator.language_code(self.english_locale.language_code), "en"
)
self.assertEqual(
self.translator.language_code(self.french_locale.language_code), "fr"
)
self.assertEqual(self.translator.language_code("foo-bar-baz"), "foo")

@mock.patch("requests.post")
def test_translate_text(self, mock_post):
# Mock the response of requests.post
mock_response = mock.Mock()
mock_response.json.return_value = {
"translatedText": [
"Bonjour le monde!",
"Ceci est une phrase. Ceci est une autre phrase.",
]
}
mock_response.raise_for_status = mock.Mock()
mock_post.return_value = mock_response

input_strings = [
StringValue("Hello world!"),
StringValue("This is a sentence. This is another sentence."),
]

translations = self.translator.translate(
self.english_locale, self.french_locale, input_strings
)

expected_translations = {
StringValue("Hello world!"): StringValue("Bonjour le monde!"),
StringValue("This is a sentence. This is another sentence."): StringValue(
"Ceci est une phrase. Ceci est une autre phrase."
),
}

# Assertions to check if the translation is as expected
self.assertEqual(translations, expected_translations)

# Assert that requests.post was called with the correct arguments
mock_post.assert_called_once_with(
LIBRETRANSLATE_SETTINGS_ENDPOINT["OPTIONS"]["LIBRETRANSLATE_URL"]
+ "/translate",
data=json.dumps(
{
"q": [
"Hello world!",
"This is a sentence. This is another sentence.",
],
"source": "en",
"target": "fr",
"api_key": LIBRETRANSLATE_SETTINGS_ENDPOINT["OPTIONS"]["API_KEY"],
}
),
headers={"Content-Type": "application/json"},
timeout=10,
)

@mock.patch("requests.post")
def test_translate_html(self, mock_post):
# Mock the response of requests.post
mock_response = mock.Mock()
mock_response.json.return_value = {
"translatedText": ["""<a id="a1">Bonjour !</a>. <b>C'est un test</b>."""]
}
mock_response.raise_for_status = mock.Mock()
mock_post.return_value = mock_response

input_string, attrs = StringValue.from_source_html(
'<a href="https://en.wikipedia.org/wiki/World">Hello !</a>. <b>This is a test</b>.'
)

translations = self.translator.translate(
self.english_locale, self.french_locale, [input_string]
)

expected_translation = {
input_string: StringValue(
"""<a id="a1">Bonjour !</a>. <b>C'est un test</b>."""
)
}

# Assertions to check if the translation is as expected
self.assertEqual(translations, expected_translation)

# Additional assertion to check the rendered HTML
translated_string = translations[input_string]
rendered_html = translated_string.render_html(attrs)
expected_rendered_html = '<a href="https://en.wikipedia.org/wiki/World">Bonjour !</a>. <b>C\'est un test</b>.'

self.assertEqual(rendered_html, expected_rendered_html)

# Assert that requests.post was called with the correct arguments
mock_post.assert_called_once_with(
LIBRETRANSLATE_SETTINGS_ENDPOINT["OPTIONS"]["LIBRETRANSLATE_URL"]
+ "/translate",
data=json.dumps(
{
"q": [
'<a id="a1">Hello !</a>. <b>This is a test</b>.'
], # Use the string from StringValue
"source": "en",
"target": "fr",
"api_key": LIBRETRANSLATE_SETTINGS_ENDPOINT["OPTIONS"]["API_KEY"],
}
),
headers={"Content-Type": "application/json"},
timeout=10,
)

def test_can_translate(self):
self.assertIsInstance(self.translator, LibreTranslator)

french_locale = Locale.objects.create(language_code="fr")

self.assertTrue(
self.translator.can_translate(self.english_locale, self.french_locale)
)
self.assertTrue(
self.translator.can_translate(self.english_locale, french_locale)
)

# Can't translate the same language
self.assertFalse(
self.translator.can_translate(self.english_locale, self.english_locale)
)

# Can't translate two variants of the same language
self.assertFalse(
self.translator.can_translate(self.french_locale, french_locale)
)