Skip to content
Snippets Groups Projects
Commit 3a4d39d7ce3a authored by Vincent Hatakeyama's avatar Vincent Hatakeyama
Browse files

Stop using hook to add methods to base when inheriting works.

Add formatter class to ease creating compute methods.
Add more caches
parent db8e0d2c38e4
No related branches found
No related tags found
1 merge request!6Topic/13.0/improvements
Pipeline #27797 passed
include:
- project: xcg/ci-templates
file: /odoo/13.0/gitlab-ci.yaml
- project: xcg/ci-templates
file: /odoo/13.0/gitlab-ci.yaml
......@@ -7,6 +7,12 @@
Add code to handle currency formatting.
Stop using hook to add methods to base when inheriting works.
Add formatter class to ease creating compute methods.
Add more caches.
13.0.1.0.0
----------
......
from . import models # noqa: F401
from .hooks import post_load # noqa: F401
from .icu_format import icu_format, icu_list_format # noqa: F401
from .icu_format import ICUFormatter, icu_format, icu_list_format # noqa: F401
from .logger import get_logger # noqa: F401
......@@ -17,5 +17,4 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
import datetime
import logging
......@@ -21,9 +20,3 @@
import logging
from typing import Dict, Mapping, Union
from odoo import _
from odoo.models import BaseModel
from .icu_format import icu_format, icu_list_format
from babel.messages import extract
......@@ -28,4 +21,3 @@
from babel.messages import extract
from icu import ICUError
......@@ -31,10 +23,7 @@
_ICU_FORMAT_TRANSLATE = "icu_format_translate"
"""Name of the function to call for translation. Do not use in other modules,
use its values instead."""
TRANSLATORS_TAG = "TRANSLATORS: "
_logger = logging.getLogger(__name__)
def post_load():
......@@ -35,10 +24,10 @@
TRANSLATORS_TAG = "TRANSLATORS: "
_logger = logging.getLogger(__name__)
def post_load():
__add_base_model_methods()
"""Post load hook"""
__patch_babel_extract()
......@@ -42,81 +31,9 @@
__patch_babel_extract()
def __add_base_model_methods():
"""Add methods to BaseModel to use icu formatting.
"""
def icu_format_locale_args(self) -> Mapping[str, str]:
return dict()
def model_icu_format(
self,
msg: str,
args: Dict[
str, Union[int, str, datetime.date, datetime.datetime, bool, float]
],
raise_exception: bool = False,
) -> str:
"""Provide a faster way to format the message, using the context lang,
or the user lang if there is no context.
"""
return icu_format(
self.env.context.get("lang") or self.env.user.lang,
msg,
args,
raise_exception,
locale_args=self.icu_format_locale_args(),
)
def model_icu_format_translate(
self,
untranslated_message: str,
args: Dict[
str, Union[int, str, datetime.date, datetime.datetime, bool, float]
],
raise_exception: bool = False,
) -> str:
"""Try to format and translate a message.
If the format of the translated message fails, try to format the
original message.
"""
msg = _(untranslated_message)
try:
return icu_format(
self.env.context.get("lang") or self.env.user.lang,
msg,
args,
raise_exception=True,
locale_args=self.icu_format_locale_args(),
)
except ICUError:
_logger.error(
"Error building message '%s'. Trying untranslated version",
msg,
exc_info=True,
)
return icu_format(
"C",
untranslated_message,
args,
raise_exception,
locale_args=self.icu_format_locale_args(),
)
def model_icu_list_format(self, list_data: list) -> str:
return icu_list_format(
self.env.context.get("lang") or self.env.user.lang, list_data
)
setattr(BaseModel, "icu_format", model_icu_format)
setattr(BaseModel, _ICU_FORMAT_TRANSLATE, model_icu_format_translate)
setattr(BaseModel, "icu_format_locale_args", icu_format_locale_args)
setattr(BaseModel, "icu_list_format", model_icu_list_format)
def __patch_babel_extract():
"""Patch babel extract method to add our extract keyword.
It is used by Odoo to extract terms from the code, and there is no way to
change Odoo’s function odoo.tools.translate.trans_generate.
"""
__original_extract = extract.extract
......@@ -117,9 +34,10 @@
def __patch_babel_extract():
"""Patch babel extract method to add our extract keyword.
It is used by Odoo to extract terms from the code, and there is no way to
change Odoo’s function odoo.tools.translate.trans_generate.
"""
__original_extract = extract.extract
__ICU_FORMAT_TRANSLATE = "icu_format_translate"
# Same signature as babel.message.extract.extract
def _extract(
......@@ -143,8 +61,8 @@
strip_comment_tags=True,
):
if method == "python":
if _ICU_FORMAT_TRANSLATE not in keywords:
keywords[_ICU_FORMAT_TRANSLATE] = (1,)
if __ICU_FORMAT_TRANSLATE not in keywords:
keywords[__ICU_FORMAT_TRANSLATE] = (1,)
# changed to include this tag by default
if TRANSLATORS_TAG not in comment_tags:
comment_tags += (TRANSLATORS_TAG,)
......
......@@ -18,5 +18,6 @@
#
##############################################################################
import datetime
import logging
from typing import Dict, Mapping, Optional, Union
......@@ -21,4 +22,4 @@
from typing import Dict, Mapping, Optional, Union
from odoo.tools import frozendict
from icu import Formattable, ICUError, ListFormatter, Locale, MessageFormat
......@@ -24,5 +25,5 @@
from icu import Formattable, ICUError, ListFormatter, Locale, MessageFormat
_logger = logging.getLogger(__name__)
class Cache:
......@@ -26,4 +27,6 @@
class Cache:
"""Cache of factory produced object. No maximum capacity."""
def __init__(self, factory):
......@@ -29,3 +32,5 @@
def __init__(self, factory):
self.__data = dict()
"""factory to create items to cache"""
self.__data = {}
"""Cached data"""
self.__factory = factory
......@@ -31,2 +36,3 @@
self.__factory = factory
"""Factory to create items"""
......@@ -32,4 +38,11 @@
def get(self, item, **kwargs):
key = (item, frozendict(kwargs))
def get(self, *args, **kwargs):
"""Return an item, eventually created with the factory"""
# create a tuple out of args and kwargs
key = tuple(
tuple(arg.items()) if type(arg) == dict else arg for arg in args
) + tuple(
(key, tuple(value.items()) if type(value) == dict else value)
for key, value in kwargs.items()
)
if key not in self.__data:
......@@ -35,5 +48,5 @@
if key not in self.__data:
self.__data[key] = self.__factory(item, **kwargs)
self.__data[key] = self.__factory(*args, **kwargs)
return self.__data[key]
......@@ -37,7 +50,77 @@
return self.__data[key]
__locale_cache = Cache(Locale)
_locale_cache = Cache(Locale)
"""A Locale cache"""
class ICUFormatter:
"""Class to format message. Useful when translating several identical
messages, like when computing a field."""
__message_format_cache = Cache(MessageFormat)
"""Class MessageFormat cache"""
def __init__(
self,
msg: str,
lang: Optional[str] = None,
locale_kwargs: Optional[Mapping[str, str]] = None,
):
"""Constructor
:param msg: the message to format
:param lang: the lang to use, default to neutral locale if none is
provided
:param locale_kwargs: extra locale arguments
"""
super().__init__()
if not lang:
# Use the neutral locale if nothing is set
lang = "C"
if locale_kwargs is None:
# need to be a mapping for get to work
locale_kwargs = {}
locale = _locale_cache.get(lang, **locale_kwargs)
self.__message = msg
"""Original message"""
self.__message_format = self.__message_format_cache.get(msg, locale)
"""MessageFormat"""
def format(
self,
args: Dict[
str, Union[int, str, datetime.date, datetime.datetime, bool, float]
],
raise_exception: bool = True,
):
"""Format a message with ICU.
:param args: a dictionary of values to use to format the message
:param raise_exception: true to raise exceptions from ICU, otherwise
will return the original message without formatting
:return: formatted message
"""
key_list: list = []
value_list: list = []
for key, value in args.items():
key_list.append(key)
# convert date to datetime as pyicu wants a datetime
if isinstance(value, datetime.date):
value = datetime.datetime.combine(value, datetime.time.min)
# Convert bool to str, otherwise ICU throws some errors
if isinstance(value, bool):
value = str(value)
value_list.append(Formattable(value))
try:
return self.__message_format.format(key_list, value_list)
except ICUError:
if raise_exception:
raise
return self.__message
_icu_formatter_cache = Cache(ICUFormatter)
def icu_format(
......@@ -47,7 +130,7 @@
str, Union[int, str, datetime.date, datetime.datetime, bool, float]
],
raise_exception: bool = True,
locale_args: Optional[Mapping[str, str]] = dict(),
locale_kwargs: Optional[Mapping[str, str]] = None,
) -> str:
"""Format a message with ICU.
......@@ -56,6 +139,6 @@
:param args: a dictionary of values to use to format the message
:param raise_exception: true to raise exceptions from ICU, otherwise will
return the original message without formatting
:param locale_args: extra locale arguments
:param locale_kwargs: extra locale arguments
:return: formatted message
"""
......@@ -60,28 +143,9 @@
:return: formatted message
"""
# TODO cache the MessageFormat somehow, maybe using @lru_cache
key_list = list()
value_list = list()
for key, value in args.items():
key_list.append(key)
# convert date to datetime as pyicu wants a datetime
if isinstance(value, datetime.date):
value = datetime.datetime.combine(value, datetime.time.min)
# Convert bool to str, otherwise ICU throws some errors
if isinstance(value, bool):
value = str(value)
value_list.append(Formattable(value))
if not lang:
# Use the neutral locale if nothing is set
lang = "C"
locale = __locale_cache.get(lang, **locale_args)
try:
return MessageFormat(msg, locale).format(key_list, value_list)
except ICUError:
if raise_exception:
raise
return msg
return _icu_formatter_cache.get(msg, lang, locale_kwargs).format(
args, raise_exception
)
__list_formatter_cache = Cache(
lambda lang, *args, **kwargs: ListFormatter.createInstance(
......@@ -84,8 +148,8 @@
__list_formatter_cache = Cache(
lambda lang, *args, **kwargs: ListFormatter.createInstance(
__locale_cache.get(lang, *args, **kwargs)
_locale_cache.get(lang, *args, **kwargs)
)
)
......
from . import base # noqa: F401
from . import currency # noqa: F401
##############################################################################
#
# ICU Format, a module for Odoo
# Copyright (C) 2021 XCG Consulting <https://odoo.consulting>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
import datetime
import logging
from typing import Dict, Mapping, Union
from odoo import _, models
from ..icu_format import (
ICUFormatter,
_icu_formatter_cache,
icu_format,
icu_list_format,
)
from icu import ICUError
_logger = logging.getLogger(__name__)
class BaseModel(models.AbstractModel):
"""Add methods to BaseModel to use icu formatting.
"""
_inherit = "base"
def icu_format_locale_kwargs(self) -> Mapping[str, str]:
"""Hook for some model to inject some information into the locale
kwargs dict"""
return {}
def icu_format(
self,
msg: str,
args: Dict[
str, Union[int, str, datetime.date, datetime.datetime, bool, float]
],
raise_exception: bool = False,
) -> str:
"""Provide a faster way to format the message, using the context lang,
or the user lang if there is no context.
"""
return icu_format(
self.env.context.get("lang") or self.env.user.lang,
msg,
args,
raise_exception,
locale_kwargs=self.icu_format_locale_kwargs(),
)
def icu_format_translate(
self,
untranslated_message: str,
args: Dict[
str, Union[int, str, datetime.date, datetime.datetime, bool, float]
],
raise_exception: bool = False,
) -> str:
"""Try to format and translate a message.
If the format of the translated message fails, try to format the
original message.
"""
msg = _(untranslated_message)
try:
return icu_format(
self.env.context.get("lang") or self.env.user.lang,
msg,
args,
raise_exception=True,
locale_kwargs=self.icu_format_locale_kwargs(),
)
except ICUError:
_logger.error(
"Error building message '%s'. Trying untranslated version",
msg,
exc_info=True,
)
return icu_format(
"C",
untranslated_message,
args,
raise_exception,
locale_kwargs=self.icu_format_locale_kwargs(),
)
def icu_list_format(self, list_data: list) -> str:
return icu_list_format(
self.env.context.get("lang") or self.env.user.lang, list_data
)
def get_icu_formatter(self, message: str) -> ICUFormatter:
return _icu_formatter_cache.get(
message,
self.env.context.get("lang") or self.env.user.lang,
locale_kwargs=self.icu_format_locale_kwargs(),
)
......@@ -27,5 +27,8 @@
_inherit = "res.currency"
def icu_format_locale_args(self) -> Mapping[str, str]:
return dict(currency=self.name)
def icu_format_locale_kwargs(self) -> Mapping[str, str]:
"""Inject the currency in the dict"""
locale_kwargs = super().icu_format_locale_kwargs()
locale_kwargs["currency"] = self.name
return locale_kwargs
......@@ -21,7 +21,7 @@
from odoo import tests
from odoo.addons.icuformat import icu_format, icu_list_format
from odoo.addons.icuformat import ICUFormatter, icu_format, icu_list_format
class Test(tests.TransactionCase):
......@@ -150,10 +150,10 @@
"en",
"Your total is {total, number, currency}",
dict(total=1.25),
locale_args=dict(currency=euro_iso_code),
locale_kwargs=dict(currency=euro_iso_code),
)
self.assertEqual(result, "Your total is €1.25")
result = icu_format(
"fr",
"Your total is {total, number, currency}",
dict(total=1.25),
......@@ -154,10 +154,10 @@
)
self.assertEqual(result, "Your total is €1.25")
result = icu_format(
"fr",
"Your total is {total, number, currency}",
dict(total=1.25),
locale_args=dict(currency=euro_iso_code),
locale_kwargs=dict(currency=euro_iso_code),
)
self.assertEqual(result, "Your total is 1,25 €")
......@@ -166,3 +166,25 @@
"Your total is {total, number, currency}", dict(total=1.25)
)
self.assertEqual(result, "Your total is €1.25")
def test_formatter(self):
"""Formatter test"""
formatter = ICUFormatter("Test {total, number}")
self.assertEqual(formatter.format(dict(total=2.3)), "Test 2.3")
self.assertEqual(formatter.format(dict(total=11)), "Test 11")
formatter = ICUFormatter("Test {total, number, currency}", "fr_FR")
self.assertEqual(
formatter.format(dict(total=147802.3)), "Test 147 802,30 €"
)
formatter = ICUFormatter(
"Test {total, number, currency}", "fr_FR", {"currency": "GBP"}
)
self.assertEqual(
formatter.format(dict(total=147802.3)), "Test 147 802,30 £GB"
)
def test_record_icu_formatter(self):
user = self.env["res.users"].search([], limit=1)
formatter = user.get_icu_formatter("A message {a}")
self.assertEqual("A message a", formatter.format({"a": "a"}))
self.assertEqual("A message b", formatter.format({"a": "b"}))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment