Repository: WeblateOrg/weblate

Issue:
Add support for Microsoft Terminology Service API
It would be great to have [Microsoft Terminology Service API][1] as an machine translation service in Weblate.

It doesn't provide full phrase translation, but extracts terminology, so it should be probably shown below full translations (thus reported with lower quality rank).

There is a Python client implementation in Pontoon:

https://github.com/mozilla/pontoon/blob/8a9b9fd465225e5c40c6ce95b9eb6e4dadb97a36/pontoon/machinery/views.py#L116-L164

[1]:https://www.microsoft.com/en-us/language/Microsoft-Terminology-API


Discussion:
Hi @nijel I can work on this
Sure, you're welcome :-)

Resolving patch:
diff --git a/weblate/trans/machine/microsoft.py b/weblate/trans/machine/microsoft.py
--- a/weblate/trans/machine/microsoft.py
+++ b/weblate/trans/machine/microsoft.py
@@ -20,14 +20,20 @@
 
 from __future__ import unicode_literals
 
+from uuid import uuid4
 from datetime import timedelta
 
+from six.moves.urllib.request import Request, urlopen
+from xml.etree import ElementTree as ET
+
 from django.conf import settings
 from django.utils import timezone
+from django.template.loader import get_template
 
 from weblate.trans.machine.base import (
     MachineTranslation, MachineTranslationError, MissingConfiguration
 )
+from weblate.lang.data import DEFAULT_LANGS
 
 COGNITIVE_BASE_URL = 'https://api.cognitive.microsoft.com/sts/v1.0'
 COGNITIVE_TOKEN = COGNITIVE_BASE_URL + '/issueToken?Subscription-Key={0}'
@@ -178,3 +184,187 @@ def convert_language(self, language):
         if language in self.LANGUAGE_CONVERTER:
             return self.LANGUAGE_CONVERTER[language]
         return language.split('-')[0]
+
+
+class MicrosoftTerminologyService(MachineTranslation):
+    """
+    The Microsoft Terminology Service API.
+
+    Allows you to programmatically access the terminology,
+    definitions and user interface (UI) strings available
+    on the MS Language Portal through a web service (SOAP).
+    """
+    name = 'Microsoft Terminology'
+
+    MS_TM_BASE = 'http://api.terminology.microsoft.com'
+    MS_TM_API_URL = '{base}/Terminology.svc'.format(base=MS_TM_BASE)
+    MS_TM_SOAP_XMLNS = '{base}/terminology'.format(base=MS_TM_BASE)
+    MS_TM_SOAP_HEADER = '{xmlns}/Terminology/'.format(xmlns=MS_TM_SOAP_XMLNS)
+    MS_TM_XPATH = './/{{{xmlns}}}'.format(xmlns=MS_TM_SOAP_XMLNS)
+
+    def soap_req(self, url, http_post=False, skip_auth=False, **kwargs):
+        soap_action = kwargs.get('soap_action', '')
+        url = self.MS_TM_API_URL
+        action = self.MS_TM_SOAP_HEADER + soap_action
+        headers = {
+            'SOAPAction': (
+                '"{action}"'
+            ).format(action=action),
+            'Content-Type': 'text/xml; charset=utf-8'
+        }
+        if soap_action == 'GetLanguages':
+            payload = {'xmlns': self.MS_TM_SOAP_XMLNS,
+                       'soap_action': soap_action}
+            template = get_template('trans/machine/microsoft_terminology_get_langs.jinja')
+        elif soap_action == 'GetTranslations':
+            source = kwargs.get('source', '')
+            language = kwargs.get('language', '')
+            text = kwargs.get('text', '')
+            max_result = 5
+            if soap_action and source and language and text:
+                payload = {'action': action,
+                           'url': url,
+                           'xmlns': self.MS_TM_SOAP_XMLNS,
+                           'uuid': uuid4(),
+                           'text': text,
+                           'from_lang': source,
+                           'to_lang': language,
+                           'max_result': max_result}
+                template = get_template('trans/machine/microsoft_terminology_translate.jinja')
+        else:
+            raise MachineTranslationError(
+                'Wrong SOAP request: "{soap_action}."').format(
+                soap_action=soap_action)
+        try:
+            payload = template.render(payload)
+            request = Request(url)
+            request.timeout = 0.5
+            for header, value in headers.iteritems():
+                request.add_header(header, value)
+            request.add_data(payload)
+            handle = urlopen(request)
+        except Exception as error:
+            raise MachineTranslationError('{err}'.format(err=error))
+        return handle
+
+    def json_req(self, url, http_post=False, skip_auth=False, raw=False,
+                 **kwargs):
+        """Adapter to soap_req"""
+
+        response = self.soap_req(
+            url, http_post=False, skip_auth=True, raw=True, **kwargs)
+
+        return response
+
+    def soap_status_req(self, url, http_post=False, skip_auth=False, **kwargs):
+        """Perform SOAP request with checking response status."""
+        # Perform request
+        response = self.soap_req(url, http_post, skip_auth, **kwargs)
+
+        if response.code != 200:
+            raise MachineTranslationError(response.msg)
+
+        return response
+
+    def json_status_req(self, url, http_post=False, skip_auth=False, **kwargs):
+        """Perform SOAP request with checking response status."""
+        # Perform request
+        response = self.soap_status_req(url,
+                                        http_post,
+                                        skip_auth=True,
+                                        **kwargs)
+
+        if response.code != 200:
+            raise MachineTranslationError(response.msg)
+
+        return response
+
+    def download_languages(self):
+        """Get list of supported languages."""
+        soap_action = 'GetLanguages'
+        soap_target = 'GetLanguagesResult'
+        soap_target_envelop = 'Code'
+        languages = []
+        xpath = self.MS_TM_XPATH
+        resp = self.soap_status_req(self.MS_TM_API_URL,
+                                    soap_action=soap_action)
+        root = ET.fromstring(resp.read())
+        results = root.find(xpath + soap_target)
+        if results is not None:
+            for lang in results:
+                languages.append(lang.find(xpath + soap_target_envelop).text)
+        return languages
+
+    def download_translations(self, source, language, text, unit, user):
+        """Download list of possible translations from the service."""
+        soap_action = 'GetTranslations'
+        soap_target = 'GetTranslationsResult'
+        soap_target_translated = 'TranslatedText'
+        soap_target_confidence = 'ConfidenceLevel'
+        soap_target_original = 'OriginalText'
+        translations = []
+        xpath = self.MS_TM_XPATH
+        resp = self.soap_status_req(self.MS_TM_API_URL,
+                                    soap_action=soap_action,
+                                    source=source,
+                                    language=language,
+                                    text=text)
+        root = ET.fromstring(resp.read())
+        results = root.find(xpath + soap_target)
+        if results is not None:
+            for translation in results:
+                translations.append(tuple([
+                    translation.find(xpath + soap_target_translated).text,
+                    int(translation.find(xpath + soap_target_confidence).text),
+                    self.name,
+                    translation.find(xpath + soap_target_original).text
+                ]))
+        return translations
+
+    def translate(self, language, text, unit, user):
+        """Return list of machine translations."""
+        if text == '':
+            return []
+
+        language = self.convert_language(language)
+        source = self.convert_language(
+            unit.translation.subproject.project.source_language.code
+        )
+        if not self.is_supported(source, language):
+            # Try adding country code from DEFAULT_LANGS
+            if '-' not in language or '_' not in language:
+                for lang in DEFAULT_LANGS:
+                    if lang.split('_')[0] == language:
+                        language = lang.replace('_', '-').lower()
+                        break
+            if '-' not in source or '_' not in source:
+                for lang in DEFAULT_LANGS:
+                    if lang.split('_')[0] == source:
+                        source = lang.replace('_', '-').lower()
+                        break
+            if source == language:
+                return []
+            if not self.is_supported(source, language):
+                return []
+        try:
+            translations = self.download_translations(
+                source, language, text, unit, user
+            )
+            return [
+                {
+                    'text': trans[0],
+                    'quality': trans[1],
+                    'service': trans[2],
+                    'source': trans[3]
+                }
+                for trans in translations
+            ]
+        except Exception as exc:
+            self.report_error(
+                exc,
+                'Failed to fetch translations from %s',
+            )
+            raise MachineTranslationError('{0}: {1}'.format(
+                exc.__class__.__name__,
+                str(exc)
+            ))


Test patch:
diff --git a/weblate/trans/tests/test_machine.py b/weblate/trans/tests/test_machine.py
--- a/weblate/trans/tests/test_machine.py
+++ b/weblate/trans/tests/test_machine.py
@@ -37,7 +37,9 @@
 )
 from weblate.trans.machine.tmserver import AmagamaTranslation
 from weblate.trans.machine.microsoft import (
-    MicrosoftTranslation, MicrosoftCognitiveTranslation,
+    MicrosoftTranslation,
+    MicrosoftCognitiveTranslation,
+    MicrosoftTerminologyService
 )
 from weblate.trans.machine.google import GoogleTranslation, GOOGLE_API_ROOT
 from weblate.trans.machine.yandex import YandexTranslation
@@ -249,6 +251,53 @@ def test_microsoft_cognitive(self):
         machine = MicrosoftCognitiveTranslation()
         self.assert_translate(machine)
 
+    @httpretty.activate
+    def test_microsoft_terminology(self):
+        httpretty.register_uri(
+            httpretty.POST,
+            'http://api.terminology.microsoft.com/Terminology.svc',
+            body="""
+                <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
+                  xmlns="http://api.terminology.microsoft.com/terminology">
+                  <soapenv:Header/>
+                  <soapenv:Body>
+                    <GetLanguages/>
+                  </soapenv:Body>
+                </soapenv:Envelope>
+            """,
+            content_type='text/xml',
+            adding_headers={'SOAPAction': ('"http://api.terminology.microsoft.com/terminology/Terminology/GetLanguages"')}
+        )
+        httpretty.register_uri(
+            httpretty.POST,
+            'http://api.terminology.microsoft.com/Terminology.svc',
+            body="""
+                    <soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
+                        <soap-env:Header>
+                            <wsa:Action xmlns:wsa="http://www.w3.org/2005/08/addressing">http://api.terminology.microsoft.com/terminology/Terminology/GetTranslations</wsa:Action>
+                            <wsa:MessageID xmlns:wsa="http://www.w3.org/2005/08/addressing">urn:uuid:15534598-4744-485b-a901-e1dcf8b3ff67</wsa:MessageID>
+                            <wsa:To xmlns:wsa="http://www.w3.org/2005/08/addressing">http://api.terminology.microsoft.com/Terminology.svc</wsa:To>
+                        </soap-env:Header>
+                        <soap-env:Body>
+                            <ns0:GetTranslations xmlns:ns0="http://api.terminology.microsoft.com/terminology">
+                                <ns0:text>world</ns0:text>
+                                <ns0:from>en-US</ns0:from>
+                                <ns0:to>es-MX</ns0:to>
+                                <ns0:sources>
+                                    <ns0:TranslationSource>Terms</ns0:TranslationSource>
+                                    <ns0:TranslationSource>UiStrings</ns0:TranslationSource>
+                                </ns0:sources>
+                                <ns0:maxTranslations>5</ns0:maxTranslations>
+                            </ns0:GetTranslations>
+                        </soap-env:Body>
+                    </soap-env:Envelope>""",
+            content_type='text/xml',
+            adding_headers={'SOAPAction': ('"http://api.terminology.microsoft.com/terminology/Terminology/GetTranslations"')}
+        )
+        machine = MicrosoftTerminologyService()
+        self.assert_translate(machine)
+        self.assert_translate(machine, lang='es-MX')
+
     @override_settings(MT_GOOGLE_KEY='KEY')
     @httpretty.activate
     def test_google(self):