3
\$\begingroup\$

The following excerpt is part of a web application microservice framework.
The goal of the provided module is to define a base class Message that may be configured by INI-style configuration files providing the message text in several languages. The code is limited to Python 3.4.

"""HIS web API messages."""

from configparser import ConfigParser

from flask import request

from wsgilib import JSON

__all__ = ['MessageNotFound', 'LanguageNotFound', 'Message']


class MessageNotFound(Exception):
    """Indicates that the respective message could not be found."""

    def __init__(self, message):
        """Sets the respective message."""
        super().__init__(message)
        self.message = message


class LanguageNotFound(Exception):
    """Indicates that the respective language could not be found."""

    def __init__(self, lang):
        """Sets the respective language."""
        super().__init__(lang)
        self.lang = lang


class MetaMessage(type):
    """Metaclass for messages."""

    def __init__(cls, *args, **kwargs):
        """Reads and sets the message's respective locales."""
        super().__init__(*args, **kwargs)

        try:
            locales = cls.LOCALES
        except AttributeError:
            pass
        else:
            if isinstance(locales, str):
                cls.LOCALES = ConfigParser()
                cls.LOCALES.read(locales)

    @property
    def locales(cls):
        """Returns the message's locales."""
        try:
            return cls.LOCALES[cls.__name__]
        except KeyError:
            raise MessageNotFound(cls.__name__)


class Message(JSON, metaclass=MetaMessage):
    """Messages returned by the respective web application."""

    STATUS = 200

    def __init__(self, *data, status=None, **fields):
        """Initializes the message."""
        language = request.args.get('lang', 'de_DE')

        try:
            message = self.__class__.locales[language]  # Class property!
        except KeyError:
            raise LanguageNotFound(language)

        if status is None:
            status = self.__class__.STATUS

        if data:
            message = message.format(*data)

        dictionary = {'message': message}
        dictionary.update(fields)
        super().__init__(dictionary, status=status)

The super class of Message, JSON, is defined here.

Example Usage in account.py:

"""Account related messages."""

from his.messages.api import Message

__all__ = [
    'NoAccountSpecified',
    'NoSuchAccount',
    'AccountLocked',
    'AccountCreated',
    'AccountDeleted',
    'AccountPatched',
    'NotAuthorized',
    'AccountExists',
    'AccountsExhausted',
    'PasswordTooShort']


class _AccountMessage(Message):
    """Abstract common account message."""

    LOCALES = '/etc/his.d/locale/his/account.ini'


class NoAccountSpecified(_AccountMessage):
    """Indicates that no account has been specified."""

    STATUS = 406


class NoSuchAccount(_AccountMessage):
    """Indicates that an account with the specified name does not exist."""

    STATUS = 404


class AccountLocked(_AccountMessage):
    """Indicates that the account is locked."""

    STATUS = 423


class AccountCreated(_AccountMessage):
    """Indicates that the account has been created."""

    STATUS = 201


class AccountDeleted(_AccountMessage):
    """Indicates that the account has been deleted."""

    STATUS = 200


class AccountPatched(_AccountMessage):
    """Indicates that the account has been patched."""

    STATUS = 200


class NotAuthorized(_AccountMessage):
    """Indicates that the an account is not
    authorized to perform the respective action.
    """

    STATUS = 403


class AccountExists(_AccountMessage):
    """Indicates that the respective account already exists."""

    STATUS = 409


class AccountsExhausted(_AccountMessage):
    """Indicates that the respective customer has
    exhauseted their respective account quota.
    """

    STATUS = 402


class PasswordTooShort(_AccountMessage):
    """Indicates that the provided password is too short."""

    STATUS = 415

The respective locales in account.ini:

[NoAccountSpecified]
de_DE = Kein Benutzerkonto angegeben.
en_US = No account specified.

[NoSuchAccount]
de_DE = Benutzerkonto nicht gefunden.
en_US = No such account.

[AccountLocked]
de_DE = Account gesperrt.
en_US = Account locked.

[AccountCreated]
de_DE = Account erstellt.
en_US = Account created.

[AccountDeleted]
de_DE = Account gelöscht.
en_US = Account deleted.

[AccountPatched]
de_DE = Account geändert.
en_US = Account patched.

[NotAuthorized]
de_DE = Zugriff verweigert.
en_US = Not authorized.

[AccountExists]
de_DE = Account existiert bereits.
en_US = Account already exists.

[AccountsExhausted]
de_DE = Maximale Anzahl an Accounts erreicht.
en_US = Account quota exhausted.

[PasswordTooShort]
de_DE = Das angegebene Passwort ist zu kurz.
en_US = The provided password is too short.

I'd like to get feedback on possible implementation optimizations.

\$\endgroup\$
2
  • \$\begingroup\$ Any reason not to use gettext? \$\endgroup\$ Jun 14, 2018 at 12:09
  • \$\begingroup\$ Didn't know that library until now. I'll have a look at it. \$\endgroup\$ Jun 14, 2018 at 14:15

1 Answer 1

1
\$\begingroup\$

It seems to me that you are somewhat reinventing gettext. So if your system allows it, you could use it instead.

You would need to change your api.py to:

"""HIS web API messages."""

import gettext

from flask import request
from wsgilib import JSON

__all__ = ['LanguageNotFound', 'Message']


class LanguageNotFound(Exception):
    """Indicates that the respective language could not be found."""

    def __init__(self, lang):
        """Sets the respective language."""
        super().__init__(lang)
        self.lang = lang


def get_translation(domain, language):
    """Create translation function for the given domain.mo files. The
    files are searched for in /etc/his.d/locales/.

    Returns a couple of functions: the first one to translate strings
    and the second one to translate pluralized strings.
    """
    locales_dir = '/etc/his.d/locales'
    try:
        translation = gettext.translation(domain, locales_dir, [language])
    except OSError as e:
        raise LanguageNotFound(language) from e
    else:
        return translation.gettext, translation.ngettext


class Message(JSON):
    """Messages returned by the respective web application."""

    STATUS = 200
    DOMAIN = 'his'

    def __init__(self, *data, status=None, **fields):
        """Initializes the message."""
        language = request.args.get('lang', 'de_DE')
        translate, _ = get_translation(self.DOMAIN, language)

        message = translate(self.__class__.__name__)

        if status is None:
            status = self.STATUS

        if data:
            message = message.format(*data)

        dictionary = {'message': message}
        dictionary.update(fields)
        super().__init__(dictionary, status=status)

And then, the only change needed to your various consumers would be to change the LOCALES class attribute to a DOMAIN one. e.g.:

class _AccountMessage(Message):
    """Abstract common account message."""

    DOMAIN = 'account'

But provided that all your api consumers are in their own module, which is not the __main__ one, you can simplify further and remove the need for the base class such as _AccountMessage:

class Message(JSON):
    """Messages returned by the respective web application."""

    STATUS = 200

    def __init__(self, *data, status=None, **fields):
        """Initializes the message."""
        language = request.args.get('lang', 'de_DE')
        translate, _ = get_translation(self.__module__, language)

        message = translate(self.__class__.__name__)

        if status is None:
            status = self.STATUS

        if data:
            message = message.format(*data)

        dictionary = {'message': message}
        dictionary.update(fields)
        super().__init__(dictionary, status=status)

and then all your public messages could derive directly from Message. e.g.:

class NoAccountSpecified(Message):
    """Indicates that no account has been specified."""

    STATUS = 406

Now to use this API, you’ll have to conform to gettext specifications. First of all, you’ll need to gather language directories under your locales directory. For instance:

/etc/his.d/locales/de/LC_MESSAGES
/etc/his.d/locales/de_DE/LC_MESSAGES
/etc/his.d/locales/en/LC_MESSAGES
/etc/his.d/locales/en_US/LC_MESSAGES

I recommend having the generic language as a fallback to the specific region. Symbolic links can help remove redundancies so de_DE could be a symbolic link to de for instance.

Next you’ll have to put the domains inside each locale folder. Judging by your example, you’ll at least need an account.mo in each folder. To do so, prepare the translation file somewhat similarly to your .ini files as .po files. e.g. for account.po:

# Translations template for PROJECT.
# Copyright (C) 2016 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2016.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2018-20-06 22:14+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Hand\n"

#: account.py:18
msgid "NoAccountSpecified"
msgstr "No account specified."

#: account.py:24
msgid "NoSuchAccount"
msgstr "No such account."

#: etc…

Then account.po need to be copied for each targeted locale and the msgstr need to be translated. Then each .po need to be converted to a .mo using the msgfmt utility:

$ cd /etc/his.d/locales/de/LC_LOCALES
$ msgfmt account.po

Also note that since you might change language frequently, it may be a good idea to load each language at startup and switch language on the fly.

Or a simple cache could do:

@functools.lru_cache(maxsize=None)
def get_translation(domain, language):
    """Create translation function for the given domain.mo files. The
    files are searched for in /etc/his.d/locales/.

    Returns a couple of functions: the first one to translate strings
    and the second one to translate pluralized strings.
    """
    locales_dir = '/etc/his.d/locales'
    try:
        translation = gettext.translation(domain, locales_dir, [language])
    except OSError as e:
        raise LanguageNotFound(language) from e
    else:
        return translation.gettext, translation.ngettext
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.