Add typing

Change-Id: I2a31ff8a9c034779b813f531ccf7b2cb166d5e07
Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
This commit is contained in:
Stephen Finucane
2025-07-31 15:41:43 +01:00
parent 97f55ec2ea
commit 3d24243f02
12 changed files with 180 additions and 81 deletions

View File

@@ -15,6 +15,7 @@
# under the License.
"""Translation function factory"""
from collections.abc import Callable
import gettext
import os
@@ -34,7 +35,10 @@ CONTEXT_SEPARATOR = _message.CONTEXT_SEPARATOR
class TranslatorFactory:
"Create translator functions"
def __init__(self, domain, localedir=None):
domain: str
localedir: str | None
def __init__(self, domain: str, localedir: str | None = None) -> None:
"""Establish a set of translation functions for the domain.
:param domain: Name of translation domain,
@@ -49,7 +53,9 @@ class TranslatorFactory:
localedir = os.environ.get(variable_name)
self.localedir = localedir
def _make_translation_func(self, domain=None):
def _make_translation_func(
self, domain: str | None = None
) -> Callable[[str], str | _message.Message]:
"""Return a translation function ready for use with messages.
The returned function takes a single value, the unicode string
@@ -74,7 +80,7 @@ class TranslatorFactory:
# on the python version.
m = t.gettext
def f(msg):
def f(msg: str) -> str | _message.Message:
"""oslo_i18n.gettextutils translation function."""
if _lazy.USE_LAZY:
return _message.Message(msg, domain=domain)
@@ -82,7 +88,9 @@ class TranslatorFactory:
return f
def _make_contextual_translation_func(self, domain=None):
def _make_contextual_translation_func(
self, domain: str | None = None
) -> Callable[[str, str], str | _message.Message]:
"""Return a translation function ready for use with context messages.
The returned function takes two values, the context of
@@ -103,7 +111,7 @@ class TranslatorFactory:
# on the python version.
m = t.gettext
def f(ctx, msg):
def f(ctx: str, msg: str) -> str | _message.Message:
"""oslo.i18n.gettextutils translation with context function."""
if _lazy.USE_LAZY:
msgid = (ctx, msg)
@@ -120,7 +128,9 @@ class TranslatorFactory:
return f
def _make_plural_translation_func(self, domain=None):
def _make_plural_translation_func(
self, domain: str | None = None
) -> Callable[[str, str, int], str | _message.Message]:
"""Return a plural translation function ready for use with messages.
The returned function takes three values, the single form of
@@ -142,7 +152,9 @@ class TranslatorFactory:
# on the python version.
m = t.ngettext
def f(msgsingle, msgplural, msgcount):
def f(
msgsingle: str, msgplural: str, msgcount: int
) -> str | _message.Message:
"""oslo.i18n.gettextutils plural translation function."""
if _lazy.USE_LAZY:
msgid = (msgsingle, msgplural, msgcount)
@@ -154,12 +166,12 @@ class TranslatorFactory:
return f
@property
def primary(self):
def primary(self) -> Callable[[str], str | _message.Message]:
"The default translation function."
return self._make_translation_func()
@property
def contextual_form(self):
def contextual_form(self) -> Callable[[str, str], str | _message.Message]:
"""The contextual translation function.
The returned function takes two values, the context of
@@ -171,7 +183,7 @@ class TranslatorFactory:
return self._make_contextual_translation_func()
@property
def plural_form(self):
def plural_form(self) -> Callable[[str, str, int], str | _message.Message]:
"""The plural translation function.
The returned function takes three values, the single form of
@@ -183,25 +195,27 @@ class TranslatorFactory:
"""
return self._make_plural_translation_func()
def _make_log_translation_func(self, level):
def _make_log_translation_func(
self, level: str
) -> Callable[[str], str | _message.Message]:
return self._make_translation_func(self.domain + '-log-' + level)
@property
def log_info(self):
def log_info(self) -> Callable[[str], str | _message.Message]:
"Translate info-level log messages."
return self._make_log_translation_func('info')
@property
def log_warning(self):
def log_warning(self) -> Callable[[str], str | _message.Message]:
"Translate warning-level log messages."
return self._make_log_translation_func('warning')
@property
def log_error(self):
def log_error(self) -> Callable[[str], str | _message.Message]:
"Translate error-level log messages."
return self._make_log_translation_func('error')
@property
def log_critical(self):
def log_critical(self) -> Callable[[str], str | _message.Message]:
"Translate critical-level log messages."
return self._make_log_translation_func('critical')

View File

@@ -20,6 +20,8 @@ import copy
import gettext
import locale
import os
from typing import Any, Literal, overload
from collections.abc import Iterable
from oslo_i18n import _factory
from oslo_i18n import _locale
@@ -30,7 +32,7 @@ __all__ = [
]
def install(domain):
def install(domain: str) -> None:
"""Install a _() function using the given translation domain.
Given a translation domain, install a _() function using gettext's
@@ -49,7 +51,7 @@ def install(domain):
builtins.__dict__['_'] = tf.primary
_AVAILABLE_LANGUAGES = {}
_AVAILABLE_LANGUAGES: dict[str, list[str]] = {}
# Copied from Babel so anyone using aliases that were previously provided by
# the Babel implementation of get_available_languages continues to work. These
# are not recommended for use in new code.
@@ -96,7 +98,7 @@ _BABEL_ALIASES = {
}
def get_available_languages(domain):
def get_available_languages(domain: str) -> list[str]:
"""Lists the available languages for the given translation domain.
:param domain: the domain to get languages for
@@ -106,7 +108,7 @@ def get_available_languages(domain):
localedir = os.environ.get(_locale.get_locale_dir_variable_name(domain))
def find(x):
def find(x: str) -> str | None:
return gettext.find(domain, localedir=localedir, languages=[x])
# NOTE(mrodden): en_US should always be available (and first in case
@@ -125,10 +127,54 @@ def get_available_languages(domain):
_original_find = gettext.find
_FIND_CACHE = {}
_FIND_CACHE: dict[
tuple[str, str | None, tuple[str, ...] | None, bool | int], Any
] = {}
def cached_find(domain, localedir=None, languages=None, all=0):
@overload
def cached_find(
domain: str,
localedir: str | None = None,
languages: Iterable[str] | None = None,
all: Literal[False] = False,
) -> str | None: ...
@overload
def cached_find(
domain: str,
localedir: str | None = None,
languages: Iterable[str] | None = None,
*,
all: Literal[True],
) -> list[str]: ...
@overload
def cached_find(
domain: str,
localedir: str | None,
languages: Iterable[str] | None,
all: Literal[True],
) -> list[str]: ...
@overload
def cached_find(
domain: str,
localedir: str | None = None,
languages: Iterable[str] | None = None,
all: bool = False,
) -> Any: ...
def cached_find(
domain: str,
localedir: str | None = None,
languages: Iterable[str] | None = None,
all: bool = False,
) -> Any:
"""A version of gettext.find using a cache.
gettext.find looks for mo files on the disk using os.path.exists. Those
@@ -149,4 +195,4 @@ def cached_find(domain, localedir=None, languages=None, all=0):
return result
gettext.find = cached_find
gettext.find = cached_find # type: ignore[assignment]

View File

@@ -21,7 +21,7 @@ __all__ = [
USE_LAZY = False
def enable_lazy(enable=True):
def enable_lazy(enable: bool = True) -> None:
"""Convenience function for configuring _() to use lazy gettext
Call this at the start of execution to enable the gettextutils._

View File

@@ -15,7 +15,7 @@
# under the License.
def get_locale_dir_variable_name(domain):
def get_locale_dir_variable_name(domain: str) -> str:
"""Build environment variable name for local dir.
Convert a translation domain name to a variable for specifying

View File

@@ -20,6 +20,7 @@ import gettext
import locale
import logging
import os
from typing import Any
import warnings
from oslo_i18n import _locale
@@ -40,16 +41,23 @@ class Message(str):
and can be treated as such.
"""
# Declare attributes that are set in __new__
msgid: str | tuple[str, str] | tuple[str, str, int]
domain: str
params: Any
has_contextual_form: bool
has_plural_form: bool
def __new__(
cls,
msgid,
msgtext=None,
params=None,
domain='oslo',
has_contextual_form=False,
has_plural_form=False,
*args,
):
msgid: str | tuple[str, str] | tuple[str, str, int],
msgtext: str | None = None,
params: Any = None,
domain: str = 'oslo',
has_contextual_form: bool = False,
has_plural_form: bool = False,
*args: Any,
) -> 'Message':
"""Create a new Message object.
In order for translation to work gettext requires a message ID, this
@@ -72,7 +80,7 @@ class Message(str):
msg.has_plural_form = has_plural_form
return msg
def translation(self, desired_locale=None):
def translation(self, desired_locale: str | None = None) -> str:
"""Translate this message to the desired locale.
:param desired_locale: The desired locale to translate the message to,
@@ -105,12 +113,12 @@ class Message(str):
@staticmethod
def _translate_msgid(
msgid,
domain,
desired_locale=None,
has_contextual_form=False,
has_plural_form=False,
):
msgid: str | tuple[str, str] | tuple[str, str, int],
domain: str,
desired_locale: str | None = None,
has_contextual_form: bool = False,
has_plural_form: bool = False,
) -> str:
if not desired_locale:
system_locale = locale.getlocale(locale.LC_CTYPE)
# If the system locale is not available to the runtime use English
@@ -132,6 +140,9 @@ class Message(str):
if not has_contextual_form and not has_plural_form:
# This is the most common case, so check it first.
translator = lang.gettext
# narrow type
if not isinstance(msgid, str):
raise ValueError("Simple msgid must be a string")
translated_message = translator(msgid)
elif has_contextual_form and has_plural_form:
@@ -140,7 +151,13 @@ class Message(str):
raise ValueError("Unimplemented.")
elif has_contextual_form:
(msgctx, msgtxt) = msgid
# narrow type
if not isinstance(msgid, tuple) or len(msgid) != 2:
raise ValueError(
"contextual msgid must be a tuple of (context, text)"
)
msgctx, msgtxt = msgid
translator = lang.gettext
msg_with_ctx = f"{msgctx}{CONTEXT_SEPARATOR}{msgtxt}"
@@ -151,19 +168,24 @@ class Message(str):
translated_message = msgtxt
elif has_plural_form:
(msgsingle, msgplural, msgcount) = msgid
translator = lang.ngettext
translated_message = translator(msgsingle, msgplural, msgcount)
# narrow type
if not isinstance(msgid, tuple) or len(msgid) != 3:
raise ValueError(
"plural msgid must be a tuple of (singular, plural, count)"
)
msgsingle, msgplural, msgcount = msgid
translated_message = lang.ngettext(msgsingle, msgplural, msgcount)
return translated_message
def _safe_translate(self, translated_message, translated_params):
def _safe_translate(
self, translated_message: str, translated_params: Any
) -> str:
"""Trap translation errors and fall back to default translation.
:param translated_message: the requested translation
:param translated_params: the params to be inserted
:return: if parameter insertion is successful then it is the
translated_message with the translated_params inserted, if the
requested translation fails then it is the default translation
@@ -195,7 +217,7 @@ class Message(str):
return translated_message
def __mod__(self, other):
def __mod__(self, other: Any) -> 'Message':
# When we mod a Message we want the actual operation to be performed
# by the base class (i.e. unicode()), the only thing we do here is
# save the original msgid and the parameters in case of a translation
@@ -206,7 +228,7 @@ class Message(str):
)
return modded
def _sanitize_mod_params(self, other):
def _sanitize_mod_params(self, other: Any) -> Any:
"""Sanitize the object being modded with this Message.
- Add support for modding 'None' so translation supports it
@@ -216,7 +238,7 @@ class Message(str):
translated, it will be used as it was when the Message was created
"""
if other is None:
params = (other,)
params: Any = (other,)
elif isinstance(other, dict):
# Merge the dictionaries
# Copy each item in case one does not support deep copy.
@@ -233,7 +255,7 @@ class Message(str):
params = self._copy_param(other)
return params
def _copy_param(self, param):
def _copy_param(self, param: Any) -> Any:
try:
return copy.deepcopy(param)
except Exception:
@@ -241,11 +263,11 @@ class Message(str):
# python code-like objects that can't be deep-copied
return str(param)
def __add__(self, other):
def __add__(self, other: Any) -> str:
from oslo_i18n._i18n import _
msg = _('Message objects do not support addition.')
raise TypeError(msg)
def __radd__(self, other):
def __radd__(self, other: Any) -> str:
return self.__add__(other)

View File

@@ -19,7 +19,7 @@ __all__ = [
]
def translate(obj, desired_locale=None):
def translate(obj: object, desired_locale: str | None = None) -> object:
"""Gets the translated unicode representation of the given object.
If the object is not translatable it is returned as-is.
@@ -48,7 +48,7 @@ def translate(obj, desired_locale=None):
return obj
def translate_args(args, desired_locale=None):
def translate_args(args: object, desired_locale: str | None = None) -> object:
"""Translates all the translatable elements of the given arguments object.
This method is used for translating the translatable values in method

View File

@@ -12,6 +12,7 @@
"""Test fixtures for working with oslo_i18n."""
import gettext
from typing import Any
import fixtures
@@ -19,7 +20,7 @@ from oslo_i18n import _lazy
from oslo_i18n import _message
class Translation(fixtures.Fixture):
class Translation(fixtures.Fixture): # type: ignore[misc]
"""Fixture for managing translatable strings.
This class provides methods for creating translatable strings
@@ -34,7 +35,7 @@ class Translation(fixtures.Fixture):
"""
def __init__(self, domain='test-domain'):
def __init__(self, domain: str = 'test-domain') -> None:
"""Initialize the fixture.
:param domain: The translation domain. This is not expected to
@@ -44,7 +45,7 @@ class Translation(fixtures.Fixture):
"""
self.domain = domain
def lazy(self, msg):
def lazy(self, msg: str) -> _message.Message:
"""Return a lazily translated message.
:param msg: Input message string. May optionally include
@@ -54,7 +55,7 @@ class Translation(fixtures.Fixture):
"""
return _message.Message(msg, domain=self.domain)
def immediate(self, msg):
def immediate(self, msg: str) -> str:
"""Return a string as though it had been translated immediately.
:param msg: Input message string. May optionally include
@@ -65,10 +66,10 @@ class Translation(fixtures.Fixture):
return str(msg)
class ToggleLazy(fixtures.Fixture):
class ToggleLazy(fixtures.Fixture): # type: ignore[misc]
"""Fixture to toggle lazy translation on or off for a test."""
def __init__(self, enabled):
def __init__(self, enabled: bool) -> None:
"""Force lazy translation on or off.
:param enabled: Flag controlling whether to enable or disable
@@ -79,12 +80,12 @@ class ToggleLazy(fixtures.Fixture):
self._enabled = enabled
self._original_value = _lazy.USE_LAZY
def setUp(self):
def setUp(self) -> None:
super().setUp()
self.addCleanup(self._restore_original)
_lazy.enable_lazy(self._enabled)
def _restore_original(self):
def _restore_original(self) -> None:
_lazy.enable_lazy(self._original_value)
@@ -99,25 +100,26 @@ class _PrefixTranslator(gettext.NullTranslations):
"""
def __init__(self, fp=None, prefix='noprefix'):
def __init__(self, fp: Any = None, prefix: str = 'noprefix') -> None:
gettext.NullTranslations.__init__(self, fp)
self.prefix = prefix
def gettext(self, message):
def gettext(self, message: str) -> str:
msg = gettext.NullTranslations.gettext(self, message)
return self.prefix + msg
def ugettext(self, message):
msg = gettext.NullTranslations.ugettext(self, message)
def ugettext(self, message: str) -> str:
# Note: ugettext is deprecated, fallback to gettext
msg = gettext.NullTranslations.gettext(self, message)
return self.prefix + msg
def _prefix_translations(*x, **y):
def _prefix_translations(*x: Any, **y: Any) -> _PrefixTranslator:
"""Use message id prefixed with domain and language as translation"""
return _PrefixTranslator(prefix=x[0] + '/' + y['languages'][0] + ': ')
class PrefixLazyTranslation(fixtures.Fixture):
class PrefixLazyTranslation(fixtures.Fixture): # type: ignore[misc]
"""Fixture to prefix lazy translation enabled messages
Use of this fixture will cause messages supporting lazy translation to
@@ -140,12 +142,14 @@ class PrefixLazyTranslation(fixtures.Fixture):
_DEFAULT_LANG = 'en_US'
def __init__(self, languages=None, locale=None):
def __init__(
self, languages: list[str] | None = None, locale: Any = None
) -> None:
super().__init__()
self.languages = languages or [PrefixLazyTranslation._DEFAULT_LANG]
self.locale = locale
def setUp(self):
def setUp(self) -> None:
super().setUp()
self.useFixture(ToggleLazy(True))
self.useFixture(

View File

@@ -16,6 +16,7 @@
"""logging utilities for translation"""
import logging
from logging import handlers
from oslo_i18n import _translate
@@ -55,7 +56,9 @@ class TranslationHandler(handlers.MemoryHandler):
"""
def __init__(self, locale=None, target=None):
def __init__(
self, locale: str | None = None, target: logging.Handler | None = None
) -> None:
"""Initialize a TranslationHandler
:param locale: locale to use for translating messages
@@ -70,10 +73,11 @@ class TranslationHandler(handlers.MemoryHandler):
handlers.MemoryHandler.__init__(self, capacity=0, target=target)
self.locale = locale
def setFormatter(self, fmt):
self.target.setFormatter(fmt)
def setFormatter(self, fmt: logging.Formatter | None) -> None:
if self.target is not None:
self.target.setFormatter(fmt)
def emit(self, record):
def emit(self, record: logging.LogRecord) -> None:
# We save the message from the original record to restore it
# after translation, so other handlers are not affected by this
original_msg = record.msg
@@ -85,12 +89,13 @@ class TranslationHandler(handlers.MemoryHandler):
record.msg = original_msg
record.args = original_args
def _translate_and_log_record(self, record):
def _translate_and_log_record(self, record: logging.LogRecord) -> None:
record.msg = _translate.translate(record.msg, self.locale)
# In addition to translating the message, we also need to translate
# arguments that were passed to the log method that were not part
# of the main message e.g., log.info(_('Some message %s'), this_one))
record.args = _translate.translate_args(record.args, self.locale)
record.args = _translate.translate_args(record.args, self.locale) # type: ignore[assignment]
self.target.emit(record)
if self.target is not None:
self.target.emit(record)

View File

@@ -41,8 +41,10 @@ class TranslationHandlerTestCase(test_base.BaseTestCase):
self.logger.addHandler(self.translation_handler)
def test_set_formatter(self):
formatter = 'some formatter'
formatter = logging.Formatter()
self.translation_handler.setFormatter(formatter)
# narrow types https://github.com/python/mypy/issues/5088
assert self.translation_handler.target is not None
self.assertEqual(formatter, self.translation_handler.target.formatter)
@mock.patch('gettext.translation')

View File

@@ -346,7 +346,7 @@ class MessageTestCase(test_base.BaseTestCase):
obj = utils.SomeObject(message)
unicoded_obj = str(obj)
self.assertEqual(es_translation, unicoded_obj.translation('es'))
self.assertEqual(es_translation, unicoded_obj.translation('es')) # type: ignore[attr-defined]
@mock.patch('gettext.translation')
def test_translate_multiple_languages(self, mock_translation):
@@ -520,8 +520,8 @@ class MessageTestCase(test_base.BaseTestCase):
obj = utils.SomeObject(msg)
unicoded_obj = str(obj)
self.assertEqual(expected_translation, unicoded_obj.translation('es'))
self.assertEqual(default_translation, unicoded_obj.translation('XX'))
self.assertEqual(expected_translation, unicoded_obj.translation('es')) # type: ignore[attr-defined]
self.assertEqual(default_translation, unicoded_obj.translation('XX')) # type: ignore[attr-defined]
@mock.patch('gettext.translation')
def test_translate_message_with_message_parameter(self, mock_translation):

View File

@@ -46,3 +46,6 @@ docstring-code-format = true
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "S", "UP"]
ignore = ["F403"]
[tool.ruff.lint.per-file-ignores]
"oslo_i18n/tests/*" = ["S"]

View File

@@ -54,6 +54,9 @@ commands =
# We only enable the hacking (H) checks
select = H
show-source = true
# Ignore warnings that conflict with ruff-format
# H301: one import per line
ignore = H301
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build,__init__.py
[hacking]