18.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:06:50 +02:00
parent d72e748793
commit 0a7ae8db93
337 changed files with 399651 additions and 232598 deletions

View file

@ -1,6 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# When using quotation marks in translation strings, please use curly quotes (“”)
# instead of straight quotes (""). On Linux, the keyboard shortcuts are:
# AltGr + V for the opening curly quotes “
# AltGr + B for the closing curly quotes ”
from __future__ import annotations
import codecs
@ -13,18 +17,18 @@ import json
import locale
import logging
import os
from tokenize import generate_tokens, STRING, NEWLINE, INDENT, DEDENT
import polib
import re
import tarfile
import threading
import typing
import warnings
from collections import defaultdict, namedtuple
from contextlib import suppress
from datetime import datetime
from os.path import join
from pathlib import Path
from tokenize import generate_tokens, STRING, NEWLINE, INDENT, DEDENT
from babel.messages import extract
from lxml import etree, html
from markupsafe import escape, Markup
@ -32,8 +36,15 @@ from psycopg2.extras import Json
import odoo
from odoo.exceptions import UserError
from . import config, pycompat
from .misc import file_open, file_path, get_iso_codes, SKIPPED_ELEMENT_TYPES
from .config import config
from .misc import file_open, file_path, get_iso_codes, OrderedSet, ReadonlyDict, SKIPPED_ELEMENT_TYPES
__all__ = [
"_",
"LazyTranslate",
"html_translate",
"xml_translate",
]
_logger = logging.getLogger(__name__)
@ -41,9 +52,6 @@ PYTHON_TRANSLATION_COMMENT = 'odoo-python'
# translation used for javascript code in web client
JAVASCRIPT_TRANSLATION_COMMENT = 'odoo-javascript'
# used to notify web client that these translations should be loaded in the UI
# deprecated comment since Odoo 16.0
WEB_TRANSLATION_COMMENT = "openerp-web"
SKIPPED_ELEMENTS = ('script', 'style', 'title')
@ -141,12 +149,6 @@ class UNIX_LINE_TERMINATOR(csv.excel):
csv.register_dialect("UNIX", UNIX_LINE_TERMINATOR)
# FIXME: holy shit this whole thing needs to be cleaned up hard it's a mess
def encode(s):
assert isinstance(s, str)
return s
# which elements are translated inline
TRANSLATED_ELEMENTS = {
'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', 'del', 'dfn', 'em',
@ -161,7 +163,7 @@ TRANSLATED_ELEMENTS = {
TRANSLATED_ATTRS = dict.fromkeys({
'string', 'add-label', 'help', 'sum', 'avg', 'confirm', 'placeholder', 'alt', 'title', 'aria-label',
'aria-keyshortcuts', 'aria-placeholder', 'aria-roledescription', 'aria-valuetext',
'value_label', 'data-tooltip', 'data-editor-message', 'label', 'cancel-label', 'confirm-label',
'value_label', 'data-tooltip', 'label', 'cancel-label', 'confirm-label',
}, lambda e: True)
def translate_attrib_value(node):
@ -317,7 +319,7 @@ def serialize_xml(node):
return etree.tostring(node, method='xml', encoding='unicode')
MODIFIER_ATTRS = {"invisible", "readonly", "required", "column_invisible", "attrs", "states"}
MODIFIER_ATTRS = {"invisible", "readonly", "required", "column_invisible", "attrs"}
def xml_term_adapter(term_en):
"""
Returns an `adapter(term)` function that will ensure the modifiers are copied
@ -453,158 +455,218 @@ def translate_sql_constraint(cr, key, lang):
""", (lang, key))
return cr.fetchone()[0]
class GettextAlias(object):
def _get_db(self):
# find current DB based on thread/worker db name (see netsvc)
db_name = getattr(threading.current_thread(), 'dbname', None)
if db_name:
return odoo.sql_db.db_connect(db_name)
def _get_cr(self, frame, allow_create=True):
# try, in order: cr, cursor, self.env.cr, self.cr,
# request.env.cr
if 'cr' in frame.f_locals:
return frame.f_locals['cr'], False
if 'cursor' in frame.f_locals:
return frame.f_locals['cursor'], False
s = frame.f_locals.get('self')
if hasattr(s, 'env'):
return s.env.cr, False
if hasattr(s, 'cr'):
return s.cr, False
try:
from odoo.http import request
return request.env.cr, False
except RuntimeError:
pass
if allow_create:
# create a new cursor
db = self._get_db()
if db is not None:
return db.cursor(), True
return None, False
def _get_uid(self, frame):
# try, in order: uid, user, self.env.uid
if 'uid' in frame.f_locals:
return frame.f_locals['uid']
if 'user' in frame.f_locals:
return int(frame.f_locals['user']) # user may be a record
s = frame.f_locals.get('self')
return s.env.uid
def _get_lang(self, frame):
# try, in order: context.get('lang'), kwargs['context'].get('lang'),
# self.env.lang, self.localcontext.get('lang'), request.env.lang
lang = None
if frame.f_locals.get('context'):
lang = frame.f_locals['context'].get('lang')
if not lang:
kwargs = frame.f_locals.get('kwargs', {})
if kwargs.get('context'):
lang = kwargs['context'].get('lang')
if not lang:
s = frame.f_locals.get('self')
if hasattr(s, 'env'):
lang = s.env.lang
if not lang:
if hasattr(s, 'localcontext'):
lang = s.localcontext.get('lang')
if not lang:
try:
from odoo.http import request
lang = request.env.lang
except RuntimeError:
pass
if not lang:
# Last resort: attempt to guess the language of the user
# Pitfall: some operations are performed in sudo mode, and we
# don't know the original uid, so the language may
# be wrong when the admin language differs.
(cr, dummy) = self._get_cr(frame, allow_create=False)
uid = self._get_uid(frame)
if cr and uid:
env = odoo.api.Environment(cr, uid, {})
lang = env['res.users'].context_get()['lang']
return lang
def __call__(self, source, *args, **kwargs):
translation = self._get_translation(source)
assert not (args and kwargs)
if args or kwargs:
if any(isinstance(a, Markup) for a in itertools.chain(args, kwargs.values())):
translation = escape(translation)
try:
return translation % (args or kwargs)
except (TypeError, ValueError, KeyError):
bad = translation
# fallback: apply to source before logging exception (in case source fails)
translation = source % (args or kwargs)
_logger.exception('Bad translation %r for string %r', bad, source)
def get_translation(module: str, lang: str, source: str, args: tuple | dict) -> str:
"""Translate and format using a module, language, source text and args."""
# get the translation by using the language
assert lang, "missing language for translation"
if lang == 'en_US':
translation = source
else:
assert module, "missing module name for translation"
translation = code_translations.get_python_translations(module, lang).get(source, source)
# skip formatting if we have no args
if not args:
return translation
# we need to check the args for markup values and for lazy translations
args_is_dict = isinstance(args, dict)
if any(isinstance(a, Markup) for a in (args.values() if args_is_dict else args)):
translation = escape(translation)
if any(isinstance(a, LazyGettext) for a in (args.values() if args_is_dict else args)):
if args_is_dict:
args = {k: v._translate(lang) if isinstance(v, LazyGettext) else v for k, v in args.items()}
else:
args = tuple(v._translate(lang) if isinstance(v, LazyGettext) else v for v in args)
# format
try:
return translation % args
except (TypeError, ValueError, KeyError):
bad = translation
# fallback: apply to source before logging exception (in case source fails)
translation = source % args
_logger.exception('Bad translation %r for string %r', bad, source)
return translation
def _get_translation(self, source, module=None):
try:
frame = inspect.currentframe().f_back.f_back
lang = self._get_lang(frame)
if lang and lang != 'en_US':
if not module:
path = inspect.getfile(frame)
path_info = odoo.modules.get_resource_from_path(path)
module = path_info[0] if path_info else 'base'
return code_translations.get_python_translations(module, lang).get(source, source)
else:
_logger.debug('no translation language detected, skipping translation for "%r" ', source)
except Exception:
_logger.debug('translation went wrong for "%r", skipped', source)
# if so, double-check the root/base translations filenames
return source
def get_translated_module(arg: str | int | typing.Any) -> str: # frame not represented as hint
"""Get the addons name.
:param arg: can be any of the following:
str ("name_of_module") returns itself;
str (__name__) use to resolve module name;
int is number of frames to go back to the caller;
frame of the caller function
"""
if isinstance(arg, str):
if arg.startswith('odoo.addons.'):
# get the name of the module
return arg.split('.')[2]
if '.' in arg or not arg:
# module name is not in odoo.addons.
return 'base'
else:
return arg
else:
if isinstance(arg, int):
frame = inspect.currentframe()
while arg > 0:
arg -= 1
frame = frame.f_back
else:
frame = arg
if not frame:
return 'base'
if (module_name := frame.f_globals.get("__name__")) and module_name.startswith('odoo.addons.'):
# just a quick lookup because `get_resource_from_path is slow compared to this`
return module_name.split('.')[2]
path = inspect.getfile(frame)
path_info = odoo.modules.get_resource_from_path(path)
return path_info[0] if path_info else 'base'
def _get_cr(frame):
# try, in order: cr, cursor, self.env.cr, self.cr,
# request.env.cr
if 'cr' in frame.f_locals:
return frame.f_locals['cr']
if 'cursor' in frame.f_locals:
return frame.f_locals['cursor']
if (local_self := frame.f_locals.get('self')) is not None:
if (local_env := getattr(local_self, 'env', None)) is not None:
return local_env.cr
if (cr := getattr(local_self, 'cr', None)) is not None:
return cr
try:
from odoo.http import request # noqa: PLC0415
request_env = request.env
if request_env is not None and (cr := request_env.cr) is not None:
return cr
except RuntimeError:
pass
return None
def _get_uid(frame) -> int | None:
# try, in order: uid, user, self.env.uid
if 'uid' in frame.f_locals:
return frame.f_locals['uid']
if 'user' in frame.f_locals:
return int(frame.f_locals['user']) # user may be a record
if (local_self := frame.f_locals.get('self')) is not None:
if hasattr(local_self, 'env') and (uid := local_self.env.uid):
return uid
return None
def _get_lang(frame, default_lang='') -> str:
# get from: context.get('lang'), kwargs['context'].get('lang'),
if local_context := frame.f_locals.get('context'):
if lang := local_context.get('lang'):
return lang
if (local_kwargs := frame.f_locals.get('kwargs')) and (local_context := local_kwargs.get('context')):
if lang := local_context.get('lang'):
return lang
# get from self.env
log_level = logging.WARNING
local_self = frame.f_locals.get('self')
local_env = local_self is not None and getattr(local_self, 'env', None)
if local_env:
if lang := local_env.lang:
return lang
# we found the env, in case we fail, just log in debug
log_level = logging.DEBUG
# get from request?
try:
from odoo.http import request # noqa: PLC0415
request_env = request.env
if request_env and (lang := request_env.lang):
return lang
except RuntimeError:
pass
# Last resort: attempt to guess the language of the user
# Pitfall: some operations are performed in sudo mode, and we
# don't know the original uid, so the language may
# be wrong when the admin language differs.
cr = _get_cr(frame)
uid = _get_uid(frame)
if cr and uid:
env = odoo.api.Environment(cr, uid, {})
if lang := env['res.users'].context_get().get('lang'):
return lang
# fallback
if default_lang:
_logger.debug('no translation language detected, fallback to %s', default_lang)
return default_lang
# give up
_logger.log(log_level, 'no translation language detected, skipping translation %s', frame, stack_info=True)
return ''
def _get_translation_source(stack_level: int, module: str = '', lang: str = '', default_lang: str = '') -> tuple[str, str]:
if not (module and lang):
frame = inspect.currentframe()
for _index in range(stack_level + 1):
frame = frame.f_back
lang = lang or _get_lang(frame, default_lang)
if lang and lang != 'en_US':
return get_translated_module(module or frame), lang
else:
# we don't care about the module for 'en_US'
return module or 'base', 'en_US'
def get_text_alias(source: str, *args, **kwargs):
assert not (args and kwargs)
assert isinstance(source, str)
module, lang = _get_translation_source(1)
return get_translation(module, lang, source, args or kwargs)
@functools.total_ordering
class _lt:
""" Lazy code translation
class LazyGettext:
""" Lazy code translated term.
Similar to GettextAlias but the translation lookup will be done only at
Similar to get_text_alias but the translation lookup will be done only at
__str__ execution.
This eases the search for terms to translate as lazy evaluated strings
are declared early.
A code using translated global variables such as:
```
_lt = LazyTranslate(__name__)
LABEL = _lt("User")
def _compute_label(self):
context = {'lang': self.partner_id.lang}
self.user_label = LABEL
env = self.with_env(lang=self.partner_id.lang).env
self.user_label = env._(LABEL)
```
works as expected (unlike the classic GettextAlias implementation).
works as expected (unlike the classic get_text_alias implementation).
"""
__slots__ = ['_source', '_args', '_module']
__slots__ = ('_args', '_default_lang', '_module', '_source')
def __init__(self, source, *args, **kwargs):
self._source = source
def __init__(self, source, *args, _module='', _default_lang='', **kwargs):
assert not (args and kwargs)
assert isinstance(source, str)
self._source = source
self._args = args or kwargs
self._module = get_translated_module(_module or 2)
self._default_lang = _default_lang
frame = inspect.currentframe().f_back
path = inspect.getfile(frame)
path_info = odoo.modules.get_resource_from_path(path)
self._module = path_info[0] if path_info else 'base'
def _translate(self, lang: str = '') -> str:
module, lang = _get_translation_source(2, self._module, lang, default_lang=self._default_lang)
return get_translation(module, lang, self._source, self._args)
def __repr__(self):
""" Show for the debugger"""
args = {'_module': self._module, '_default_lang': self._default_lang, '_args': self._args}
return f"_lt({self._source!r}, **{args!r})"
def __str__(self):
# Call _._get_translation() like _() does, so that we have the same number
# of stack frames calling _get_translation()
translation = _._get_translation(self._source, self._module)
if self._args:
try:
return translation % self._args
except (TypeError, ValueError, KeyError):
bad = translation
# fallback: apply to source before logging exception (in case source fails)
translation = self._source % self._args
_logger.exception('Bad translation %r for string %r', bad, self._source)
return translation
""" Translate."""
return self._translate()
def __eq__(self, other):
""" Prevent using equal operators
@ -614,26 +676,50 @@ class _lt:
"""
raise NotImplementedError()
def __hash__(self):
raise NotImplementedError()
def __lt__(self, other):
raise NotImplementedError()
def __add__(self, other):
# Call _._get_translation() like _() does, so that we have the same number
# of stack frames calling _get_translation()
if isinstance(other, str):
return _._get_translation(self._source) + other
elif isinstance(other, _lt):
return _._get_translation(self._source) + _._get_translation(other._source)
return self._translate() + other
elif isinstance(other, LazyGettext):
return self._translate() + other._translate()
return NotImplemented
def __radd__(self, other):
# Call _._get_translation() like _() does, so that we have the same number
# of stack frames calling _get_translation()
if isinstance(other, str):
return other + _._get_translation(self._source)
return other + self._translate()
return NotImplemented
_ = GettextAlias()
class LazyTranslate:
""" Lazy translation template.
Usage:
```
_lt = LazyTranslate(__name__)
MYSTR = _lt('Translate X')
```
You may specify a `default_lang` to fallback to a given language on error
"""
module: str
default_lang: str
def __init__(self, module: str, *, default_lang: str = '') -> None:
self.module = module = get_translated_module(module or 2)
# set the default lang to en_US for lazy translations in the base module
self.default_lang = default_lang or ('en_US' if module == 'base' else '')
def __call__(self, source: str, *args, **kwargs) -> LazyGettext:
return LazyGettext(source, *args, **kwargs, _module=self.module, _default_lang=self.default_lang)
_ = get_text_alias
_lt = LazyGettext
def quote(s):
@ -795,9 +881,10 @@ def TranslationFileWriter(target, fileformat='po', lang=None):
'.csv, .po, or .tgz (received .%s).') % fileformat)
_writer = codecs.getwriter('utf-8')
class CSVFileWriter:
def __init__(self, target):
self.writer = pycompat.csv_writer(target, dialect='UNIX')
self.writer = csv.writer(_writer(target), dialect='UNIX')
# write header first
self.writer.writerow(("module","type","name","res_id","src","value","comments"))
@ -867,22 +954,20 @@ class PoFileWriter:
entry.comment = "module%s: %s" % (plural, ', '.join(modules))
if comments:
entry.comment += "\n" + "\n".join(comments)
code = False
for typy, name, res_id in tnrs:
if typy == 'code':
code = True
res_id = 0
if isinstance(res_id, int) or res_id.isdigit():
# second term of occurrence must be a digit
# occurrence line at 0 are discarded when rendered to string
entry.occurrences.append((u"%s:%s" % (typy, name), str(res_id)))
occurrences = OrderedSet()
for type_, *ref in tnrs:
if type_ == "code":
fpath, lineno = ref
name = f"code:{fpath}"
# lineno is set to 0 to avoid creating diff in PO files every
# time the code is moved around
lineno = "0"
else:
entry.occurrences.append((u"%s:%s:%s" % (typy, name, res_id), ''))
if code:
# TODO 17.0: remove the flag python-format in all PO/POT files
# The flag is used in a wrong way. It marks all code translations even for javascript translations.
entry.flags.append("python-format")
field_name, xmlid = ref
name = f"{type_}:{field_name}:{xmlid}"
lineno = None # no lineno for model/model_terms sources
occurrences.add((name, lineno))
entry.occurrences = list(occurrences)
self.po.append(entry)
@ -952,7 +1037,6 @@ def _extract_translatable_qweb_terms(element, callback):
if isinstance(el, SKIPPED_ELEMENT_TYPES): continue
if (el.tag.lower() not in SKIPPED_ELEMENTS
and "t-js" not in el.attrib
and not ("t-jquery" in el.attrib and "t-operation" not in el.attrib)
and not (el.tag == 'attribute' and el.get('name') not in TRANSLATED_ATTRS)
and el.get("t-translation", '').strip() != "off"):
@ -997,7 +1081,7 @@ def extract_formula_terms(formula):
tokens = generate_tokens(io.StringIO(formula).readline)
tokens = (token for token in tokens if token.type not in {NEWLINE, INDENT, DEDENT})
for t1 in tokens:
if not t1.string == '_t':
if t1.string != '_t':
continue
t2 = next(tokens, None)
if t2 and t2.string == '(':
@ -1031,16 +1115,19 @@ def extract_spreadsheet_terms(fileobj, keywords, comment_tags, options):
if markdown_link:
terms.add(markdown_link[1])
for figure in sheet['figures']:
terms.add(figure['data']['title'])
if 'baselineDescr' in figure['data']:
terms.add(figure['data']['baselineDescr'])
pivots = data.get('pivots', {}).values()
lists = data.get('lists', {}).values()
for data_source in itertools.chain(lists, pivots):
if 'name' in data_source:
terms.add(data_source['name'])
for global_filter in data.get('globalFilters', []):
terms.add(global_filter['label'])
if figure['tag'] == 'chart':
title = figure['data']['title']
if isinstance(title, str):
terms.add(title)
elif 'text' in title:
terms.add(title['text'])
if 'axesDesign' in figure['data']:
terms.update(
axes.get('title', {}).get('text', '') for axes in figure['data']['axesDesign'].values()
)
if 'baselineDescr' in figure['data']:
terms.add(figure['data']['baselineDescr'])
terms.update(global_filter['label'] for global_filter in data.get('globalFilters', []))
return (
(0, None, term, [])
for term in terms
@ -1059,7 +1146,7 @@ class TranslationReader:
def __iter__(self):
for module, source, name, res_id, ttype, comments, _record_id, value in self._to_translate:
yield (module, ttype, name, res_id, source, encode(odoo.tools.ustr(value)), comments)
yield (module, ttype, name, res_id, source, value, comments)
def _push_translation(self, module, ttype, name, res_id, source, comments=None, record_id=None, value=None):
""" Insert a translation that will be used in the file generation
@ -1310,7 +1397,7 @@ class TranslationModuleReader(TranslationReader):
lineno, message, comments = extracted[:3]
value = translations.get(message, '')
self._push_translation(module, trans_type, display_path, lineno,
encode(message), comments + extra_comments, value=value)
message, comments + extra_comments, value=value)
except Exception:
_logger.exception("Failed to extract terms from %s", fabsolutepath)
finally:
@ -1321,7 +1408,7 @@ class TranslationModuleReader(TranslationReader):
This will include:
- the python strings marked with _() or _lt()
- the javascript strings marked with _t() or _lt() inside static/src/js/
- the javascript strings marked with _t() inside static/src/js/
- the strings inside Qweb files inside static/src/xml/
- the spreadsheet data files
"""
@ -1348,7 +1435,7 @@ class TranslationModuleReader(TranslationReader):
for fname in fnmatch.filter(files, '*.js'):
self._babel_extract_terms(fname, path, root, 'javascript',
extra_comments=[JAVASCRIPT_TRANSLATION_COMMENT],
extract_keywords={'_t': None, '_lt': None})
extract_keywords={'_t': None})
# QWeb template files
for fname in fnmatch.filter(files, '*.xml'):
self._babel_extract_terms(fname, path, root, 'odoo.tools.translate:babel_extract_qweb',
@ -1629,11 +1716,7 @@ def load_language(cr, lang):
installer.lang_install()
def get_po_paths(module_name: str, lang: str):
return get_po_paths_env(module_name, lang)
def get_po_paths_env(module_name: str, lang: str, env: odoo.api.Environment | None = None):
def get_po_paths(module_name: str, lang: str, env: odoo.api.Environment | None = None):
lang_base = lang.split('_', 1)[0]
# Load the base as a fallback in case a translation is missing:
po_names = [lang_base, lang]
@ -1642,7 +1725,7 @@ def get_po_paths_env(module_name: str, lang: str, env: odoo.api.Environment | No
po_names.insert(1, 'es_419')
po_paths = (
join(module_name, dir_, filename + '.po')
for filename in po_names
for filename in OrderedSet(po_names)
for dir_ in ('i18n', 'i18n_extra')
)
for path in po_paths:
@ -1690,25 +1773,19 @@ class CodeTranslations:
def _load_python_translations(self, module_name, lang):
def filter_func(row):
# In the pot files with new translations, a code translation should have either
# PYTHON_TRANSLATION_COMMENT or JAVASCRIPT_TRANSLATION_COMMENT for comments.
# If a comment has neither the above comments, the pot file uses the deprecated
# comments. And all code translations are stored as python translations.
return row.get('value') and (
PYTHON_TRANSLATION_COMMENT in row['comments']
or JAVASCRIPT_TRANSLATION_COMMENT not in row['comments'])
return row.get('value') and PYTHON_TRANSLATION_COMMENT in row['comments']
translations = CodeTranslations._get_code_translations(module_name, lang, filter_func)
self.python_translations[(module_name, lang)] = translations
self.python_translations[(module_name, lang)] = ReadonlyDict(translations)
def _load_web_translations(self, module_name, lang):
def filter_func(row):
return row.get('value') and (
JAVASCRIPT_TRANSLATION_COMMENT in row['comments']
or WEB_TRANSLATION_COMMENT in row['comments'])
return row.get('value') and JAVASCRIPT_TRANSLATION_COMMENT in row['comments']
translations = CodeTranslations._get_code_translations(module_name, lang, filter_func)
self.web_translations[(module_name, lang)] = {
"messages": [{"id": src, "string": value} for src, value in translations.items()]
}
self.web_translations[(module_name, lang)] = ReadonlyDict({
"messages": tuple(
ReadonlyDict({"id": src, "string": value})
for src, value in translations.items())
})
def get_python_translations(self, module_name, lang):
if (module_name, lang) not in self.python_translations:
@ -1730,7 +1807,8 @@ def _get_translation_upgrade_queries(cr, field):
field's column, while the queries in ``cleanup_queries`` remove the corresponding data from
table ``_ir_translation``.
"""
Model = odoo.registry(cr.dbname)[field.model_name]
from odoo.modules.registry import Registry # noqa: PLC0415
Model = Registry(cr.dbname)[field.model_name]
translation_name = f"{field.model_name},{field.name}"
migrate_queries = []
cleanup_queries = []