19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

View file

@ -39,8 +39,6 @@ import markupsafe
import pytz
from lxml import etree, objectify
import odoo
import odoo.addons
# get_encodings, ustr and exception_to_unicode were originally from tools.misc.
# There are moved to loglevels until we refactor tools.
from odoo.loglevels import exception_to_unicode, get_encodings, ustr # noqa: F401
@ -65,6 +63,7 @@ __all__ = [
'NON_BREAKING_SPACE',
'SKIPPED_ELEMENT_TYPES',
'DotDict',
'LastOrderedSet',
'OrderedSet',
'Reverse',
'babel_locale_parse',
@ -108,6 +107,7 @@ __all__ = [
'topological_sort',
'unique',
'ustr',
'real_time',
]
_logger = logging.getLogger(__name__)
@ -124,6 +124,9 @@ objectify.set_default_parser(default_parser)
NON_BREAKING_SPACE = u'\N{NO-BREAK SPACE}'
# ensure we have a non patched time for query times when using freezegun
real_time = time.time.__call__ # type: ignore
class Sentinel(enum.Enum):
"""Class for typing parameters with a sentinel as a default"""
@ -142,9 +145,10 @@ def find_in_path(name):
path.append(config['bin_path'])
return which(name, path=os.pathsep.join(path))
#----------------------------------------------------------
# ----------------------------------------------------------
# Postgres subprocesses
#----------------------------------------------------------
# ----------------------------------------------------------
def find_pg_tool(name):
path = None
@ -152,9 +156,10 @@ def find_pg_tool(name):
path = config['pg_path']
try:
return which(name, path=path)
except IOError:
except OSError:
raise Exception('Command `%s` not found.' % name)
def exec_pg_environ():
"""
Force the database PostgreSQL environment variables to the database
@ -165,17 +170,21 @@ def exec_pg_environ():
postgres user password in the PGPASSWORD environment variable or in a
special .pgpass file.
See also http://www.postgresql.org/docs/8.4/static/libpq-envars.html
See also https://www.postgresql.org/docs/current/libpq-envars.html
"""
env = os.environ.copy()
if odoo.tools.config['db_host']:
env['PGHOST'] = odoo.tools.config['db_host']
if odoo.tools.config['db_port']:
env['PGPORT'] = str(odoo.tools.config['db_port'])
if odoo.tools.config['db_user']:
env['PGUSER'] = odoo.tools.config['db_user']
if odoo.tools.config['db_password']:
env['PGPASSWORD'] = odoo.tools.config['db_password']
if config['db_host']:
env['PGHOST'] = config['db_host']
if config['db_port']:
env['PGPORT'] = str(config['db_port'])
if config['db_user']:
env['PGUSER'] = config['db_user']
if config['db_password']:
env['PGPASSWORD'] = config['db_password']
if config['db_app_name']:
env['PGAPPNAME'] = config['db_app_name'].replace('{pid}', f'env{os.getpid()}')[:63]
if config['db_sslmode']:
env['PGSSLMODE'] = config['db_sslmode']
return env
@ -184,7 +193,7 @@ def exec_pg_environ():
# ----------------------------------------------------------
def file_path(file_path: str, filter_ext: tuple[str, ...] = ('',), env: Environment | None = None) -> str:
def file_path(file_path: str, filter_ext: tuple[str, ...] = ('',), env: Environment | None = None, *, check_exists: bool = True) -> str:
"""Verify that a file exists under a known `addons_path` directory and return its full path.
Examples::
@ -197,13 +206,12 @@ def file_path(file_path: str, filter_ext: tuple[str, ...] = ('',), env: Environm
:param list[str] filter_ext: optional list of supported extensions (lowercase, with leading dot)
:param env: optional environment, required for a file path within a temporary directory
created using `file_open_temporary_directory()`
:param check_exists: check that the file exists (default: True)
:return: the absolute path to the file
:raise FileNotFoundError: if the file is not found under the known `addons_path` directories
:raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
"""
root_path = os.path.abspath(config['root_path'])
temporary_paths = env.transaction._Transaction__file_open_tmp_paths if env else ()
addons_paths = [*odoo.addons.__path__, root_path, *temporary_paths]
import odoo.addons # noqa: PLC0415
is_abs = os.path.isabs(file_path)
normalized_path = os.path.normpath(os.path.normcase(file_path))
@ -212,15 +220,31 @@ def file_path(file_path: str, filter_ext: tuple[str, ...] = ('',), env: Environm
# ignore leading 'addons/' if present, it's the final component of root_path, but
# may sometimes be included in relative paths
if normalized_path.startswith('addons' + os.sep):
normalized_path = normalized_path[7:]
normalized_path = normalized_path.removeprefix('addons' + os.sep)
# if path is relative and represents a loaded module, accept only the
# __path__ for that module; otherwise, search in all accepted paths
file_path_split = normalized_path.split(os.path.sep)
if not is_abs and (module := sys.modules.get(f'odoo.addons.{file_path_split[0]}')):
addons_paths = list(map(os.path.dirname, module.__path__))
else:
root_path = os.path.abspath(config.root_path)
temporary_paths = env.transaction._Transaction__file_open_tmp_paths if env else ()
addons_paths = [*odoo.addons.__path__, root_path, *temporary_paths]
for addons_dir in addons_paths:
# final path sep required to avoid partial match
parent_path = os.path.normpath(os.path.normcase(addons_dir)) + os.sep
fpath = (normalized_path if is_abs else
os.path.normpath(os.path.normcase(os.path.join(parent_path, normalized_path))))
if fpath.startswith(parent_path) and os.path.exists(fpath):
if is_abs:
fpath = normalized_path
else:
fpath = os.path.normpath(os.path.join(parent_path, normalized_path))
if fpath.startswith(parent_path) and (
# we check existence when asked or we have multiple paths to check
# (there is one possibility for absolute paths)
(not check_exists and (is_abs or len(addons_paths) == 1))
or os.path.exists(fpath)
):
return fpath
raise FileNotFoundError("File not found: " + file_path)
@ -245,18 +269,20 @@ def file_open(name: str, mode: str = "r", filter_ext: tuple[str, ...] = (), env:
:raise FileNotFoundError: if the file is not found under the known `addons_path` directories
:raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
"""
path = file_path(name, filter_ext=filter_ext, env=env)
if os.path.isfile(path):
if 'b' not in mode:
# Force encoding for text mode, as system locale could affect default encoding,
# even with the latest Python 3 versions.
# Note: This is not covered by a unit test, due to the platform dependency.
# For testing purposes you should be able to force a non-UTF8 encoding with:
# `sudo locale-gen fr_FR; LC_ALL=fr_FR.iso8859-1 python3 ...'
# See also PEP-540, although we can't rely on that at the moment.
return open(path, mode, encoding="utf-8")
return open(path, mode)
raise FileNotFoundError("Not a file: " + name)
path = file_path(name, filter_ext=filter_ext, env=env, check_exists=False)
encoding = None
if 'b' not in mode:
# Force encoding for text mode, as system locale could affect default encoding,
# even with the latest Python 3 versions.
# Note: This is not covered by a unit test, due to the platform dependency.
# For testing purposes you should be able to force a non-UTF8 encoding with:
# `sudo locale-gen fr_FR; LC_ALL=fr_FR.iso8859-1 python3 ...'
# See also PEP-540, although we can't rely on that at the moment.
encoding = "utf-8"
if any(m in mode for m in ('w', 'x', 'a')) and not os.path.isfile(path):
# Don't let create new files
raise FileNotFoundError(f"Not a file: {path}")
return open(path, mode, encoding=encoding)
@contextmanager
@ -417,48 +443,6 @@ def merge_sequences(*iterables: Iterable[T]) -> list[T]:
return topological_sort(deps)
try:
import xlwt
# add some sanitization to respect the excel sheet name restrictions
# as the sheet name is often translatable, can not control the input
class PatchedWorkbook(xlwt.Workbook):
def add_sheet(self, name, cell_overwrite_ok=False):
# invalid Excel character: []:*?/\
name = re.sub(r'[\[\]:*?/\\]', '', name)
# maximum size is 31 characters
name = name[:31]
return super(PatchedWorkbook, self).add_sheet(name, cell_overwrite_ok=cell_overwrite_ok)
xlwt.Workbook = PatchedWorkbook
except ImportError:
xlwt = None
try:
import xlsxwriter
# add some sanitization to respect the excel sheet name restrictions
# as the sheet name is often translatable, can not control the input
class PatchedXlsxWorkbook(xlsxwriter.Workbook):
# TODO when xlsxwriter bump to 0.9.8, add worksheet_class=None parameter instead of kw
def add_worksheet(self, name=None, **kw):
if name:
# invalid Excel character: []:*?/\
name = re.sub(r'[\[\]:*?/\\]', '', name)
# maximum size is 31 characters
name = name[:31]
return super(PatchedXlsxWorkbook, self).add_worksheet(name, **kw)
xlsxwriter.Workbook = PatchedXlsxWorkbook
except ImportError:
xlsxwriter = None
def get_iso_codes(lang: str) -> str:
if lang.find('_') != -1:
lang_items = lang.split('_')
@ -609,10 +593,12 @@ POSIX_TO_LDML = {
'B': 'MMMM',
#'c': '',
'd': 'dd',
'-d': 'd',
'H': 'HH',
'I': 'hh',
'j': 'DDD',
'm': 'MM',
'-m': 'M',
'M': 'mm',
'p': 'a',
'S': 'ss',
@ -637,6 +623,7 @@ def posix_to_ldml(fmt: str, locale: babel.Locale) -> str:
"""
buf = []
pc = False
minus = False
quoted = []
for c in fmt:
@ -657,7 +644,13 @@ def posix_to_ldml(fmt: str, locale: babel.Locale) -> str:
buf.append(locale.date_formats['short'].pattern)
elif c == 'X': # time format, seems to include seconds. short does not
buf.append(locale.time_formats['medium'].pattern)
elif c == '-':
minus = True
continue
else: # look up format char in static mapping
if minus:
c = '-' + c
minus = False
buf.append(POSIX_TO_LDML[c])
pc = False
elif c == '%':
@ -906,7 +899,7 @@ def dumpstacks(sig=None, frame=None, thread_idents=None, log_level=logging.INFO)
perf_t0 = thread_info.get('perf_t0')
remaining_time = None
if query_time is not None and perf_t0:
remaining_time = '%.3f' % (time.time() - perf_t0 - query_time)
remaining_time = '%.3f' % (real_time() - perf_t0 - query_time)
query_time = '%.3f' % query_time
# qc:query_count qt:query_time pt:python_time (aka remaining time)
code.append("\n# Thread: %s (db:%s) (uid:%s) (url:%s) (qc:%s qt:%s pt:%s)" %
@ -920,6 +913,7 @@ def dumpstacks(sig=None, frame=None, thread_idents=None, log_level=logging.INFO)
for line in extract_stack(stack):
code.append(line)
import odoo # eventd
if odoo.evented:
# code from http://stackoverflow.com/questions/12510648/in-gevent-how-can-i-dump-stack-traces-of-all-running-greenlets
import gc
@ -1055,7 +1049,7 @@ class OrderedSet(MutableSet[T], typing.Generic[T]):
""" A set collection that remembers the elements first insertion order. """
__slots__ = ['_map']
def __init__(self, elems=()):
def __init__(self, elems: Iterable[T] = ()):
self._map: dict[T, None] = dict.fromkeys(elems)
def __contains__(self, elem):
@ -1327,32 +1321,29 @@ def formatLang(
value: float | typing.Literal[''],
digits: int = 2,
grouping: bool = True,
monetary: bool | Sentinel = SENTINEL,
dp: str | None = None,
currency_obj=None,
currency_obj: typing.Any | None = None,
rounding_method: typing.Literal['HALF-UP', 'HALF-DOWN', 'HALF-EVEN', "UP", "DOWN"] = 'HALF-EVEN',
rounding_unit: typing.Literal['decimals', 'units', 'thousands', 'lakhs', 'millions'] = 'decimals',
) -> str:
"""
This function will format a number `value` to the appropriate format of the language used.
:param Object env: The environment.
:param float value: The value to be formatted.
:param int digits: The number of decimals digits.
:param bool grouping: Usage of language grouping or not.
:param bool monetary: Usage of thousands separator or not.
.. deprecated:: 13.0
:param str dp: Name of the decimals precision to be used. This will override ``digits``
:param env: The environment.
:param value: The value to be formatted.
:param digits: The number of decimals digits.
:param grouping: Usage of language grouping or not.
:param dp: Name of the decimals precision to be used. This will override ``digits``
and ``currency_obj`` precision.
:param Object currency_obj: Currency to be used. This will override ``digits`` precision.
:param str rounding_method: The rounding method to be used:
:param currency_obj: Currency to be used. This will override ``digits`` precision.
:param rounding_method: The rounding method to be used:
**'HALF-UP'** will round to the closest number with ties going away from zero,
**'HALF-DOWN'** will round to the closest number with ties going towards zero,
**'HALF_EVEN'** will round to the closest number with ties going to the closest
even number,
**'UP'** will always round away from 0,
**'DOWN'** will always round towards 0.
:param str rounding_unit: The rounding unit to be used:
:param rounding_unit: The rounding unit to be used:
**decimals** will round to decimals with ``digits`` or ``dp`` precision,
**units** will round to units without any decimals,
**thousands** will round to thousands without any decimals,
@ -1360,10 +1351,7 @@ def formatLang(
**millions** will round to millions without any decimals.
:returns: The value formatted.
:rtype: str
"""
if monetary is not SENTINEL:
warnings.warn("monetary argument deprecated since 13.0", DeprecationWarning, 2)
# We don't want to return 0
if value == '':
return ''
@ -1418,18 +1406,19 @@ def format_date(
"""
if not value:
return ''
from odoo.fields import Datetime # noqa: PLC0415
if isinstance(value, str):
if len(value) < DATE_LENGTH:
return ''
if len(value) > DATE_LENGTH:
# a datetime, convert to correct timezone
value = odoo.fields.Datetime.from_string(value)
value = odoo.fields.Datetime.context_timestamp(env['res.lang'], value)
value = Datetime.from_string(value)
value = Datetime.context_timestamp(env['res.lang'], value)
else:
value = odoo.fields.Datetime.from_string(value)
value = Datetime.from_string(value)
elif isinstance(value, datetime.datetime) and not value.tzinfo:
# a datetime, convert to correct timezone
value = odoo.fields.Datetime.context_timestamp(env['res.lang'], value)
value = Datetime.context_timestamp(env['res.lang'], value)
lang = get_lang(env, lang_code)
locale = babel_locale_parse(lang.code)
@ -1479,7 +1468,8 @@ def format_datetime(
if not value:
return ''
if isinstance(value, str):
timestamp = odoo.fields.Datetime.from_string(value)
from odoo.fields import Datetime # noqa: PLC0415
timestamp = Datetime.from_string(value)
else:
timestamp = value
@ -1494,7 +1484,7 @@ def format_datetime(
lang = get_lang(env, lang_code)
locale = babel_locale_parse(lang.code or lang_code) # lang can be inactive, so `lang`is empty
if not dt_format:
if not dt_format or dt_format == 'medium':
date_format = posix_to_ldml(lang.date_format, locale=locale)
time_format = posix_to_ldml(lang.time_format, locale=locale)
dt_format = '%s %s' % (date_format, time_format)
@ -1533,7 +1523,8 @@ def format_time(
localized_time = value
else:
if isinstance(value, str):
value = odoo.fields.Datetime.from_string(value)
from odoo.fields import Datetime # noqa: PLC0415
value = Datetime.from_string(value)
assert isinstance(value, datetime.datetime)
tz_name = tz or env.user.tz or 'UTC'
utc_datetime = pytz.utc.localize(value, is_dst=False)
@ -1545,7 +1536,7 @@ def format_time(
lang = get_lang(env, lang_code)
locale = babel_locale_parse(lang.code)
if not time_format:
if not time_format or time_format == 'medium':
time_format = posix_to_ldml(lang.time_format, locale=locale)
return babel.dates.format_time(localized_time, format=time_format, locale=locale)
@ -1613,13 +1604,16 @@ def format_decimalized_amount(amount: float, currency=None) -> str:
return "%s %s" % (formated_amount, currency.symbol or '')
def format_amount(env: Environment, amount: float, currency, lang_code: str | None = None) -> str:
def format_amount(env: Environment, amount: float, currency, lang_code: str | None = None, trailing_zeroes: bool = True) -> str:
fmt = "%.{0}f".format(currency.decimal_places)
lang = env['res.lang'].browse(get_lang(env, lang_code).id)
formatted_amount = lang.format(fmt, currency.round(amount), grouping=True)\
.replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
if not trailing_zeroes:
formatted_amount = re.sub(fr'{re.escape(lang.decimal_point)}?0+$', '', formatted_amount)
pre = post = u''
if currency.position == 'before':
pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=currency.symbol or '')
@ -1667,25 +1661,22 @@ class ReadonlyDict(Mapping[K, T], typing.Generic[K, T]):
data.update({'baz', 'xyz'}) # raises exception
dict.update(data, {'baz': 'xyz'}) # raises exception
"""
__slots__ = ('_data__',)
def __init__(self, data):
self.__data = dict(data)
self._data__ = dict(data)
def __contains__(self, key: K):
return key in self.__data
return key in self._data__
def __getitem__(self, key: K) -> T:
try:
return self.__data[key]
except KeyError:
if hasattr(type(self), "__missing__"):
return self.__missing__(key)
raise
return self._data__[key]
def __len__(self):
return len(self.__data)
return len(self._data__)
def __iter__(self):
return iter(self.__data)
return iter(self._data__)
class DotDict(dict):
@ -1734,6 +1725,7 @@ def get_diff(data_from, data_to, custom_style=False, dark_color_scheme=False):
table.diff { width: 100%%; }
table.diff th.diff_header { width: 50%%; }
table.diff td.diff_header { white-space: nowrap; }
table.diff td.diff_header + td { width: 50%%; }
table.diff td { word-break: break-all; vertical-align: top; }
table.diff .diff_chg, table.diff .diff_sub, table.diff .diff_add {
display: inline-block;
@ -1831,9 +1823,9 @@ def verify_hash_signed(env, scope, payload):
return None
def limited_field_access_token(record, field_name, timestamp=None):
"""Generate a token granting access to the given record and field_name from
the binary routes (/web/content or /web/image).
def limited_field_access_token(record, field_name, timestamp=None, *, scope):
"""Generate a token granting access to the given record and field_name in
the given scope.
The validitiy of the token is determined by the timestamp parameter.
When it is not specified, a timestamp is automatically generated with a
@ -1847,6 +1839,9 @@ def limited_field_access_token(record, field_name, timestamp=None):
:type record: class:`odoo.models.Model`
:param field_name: the field name of record to generate the token for
:type field_name: str
:param scope: scope of the authentication, to have different signature for the same
record/field in different usage
:type scope: str
:param timestamp: expiration timestamp of the token, or None to generate one
:type timestamp: int, optional
:return: the token, which includes the timestamp in hex format
@ -1860,11 +1855,11 @@ def limited_field_access_token(record, field_name, timestamp=None):
adler32_max = 4294967295
jitter = two_weeks * zlib.adler32(unique_str.encode()) // adler32_max
timestamp = hex(start_of_period + 2 * two_weeks + jitter)
token = hmac(record.env(su=True), "binary", (record._name, record.id, field_name, timestamp))
token = hmac(record.env(su=True), scope, (record._name, record.id, field_name, timestamp))
return f"{token}o{timestamp}"
def verify_limited_field_access_token(record, field_name, access_token):
def verify_limited_field_access_token(record, field_name, access_token, *, scope):
"""Verify the given access_token grants access to field_name of record.
In particular, the token must have the right format, must be valid for the
given record, and must not have expired.
@ -1875,13 +1870,15 @@ def verify_limited_field_access_token(record, field_name, access_token):
:type field_name: str
:param access_token: the access token to verify
:type access_token: str
:param scope: scope of the authentication, to have different signature for the same
record/field in different usage
:return: whether the token is valid for the record/field_name combination at
the current date and time
:rtype: bool
"""
*_, timestamp = access_token.rsplit("o", 1)
return consteq(
access_token, limited_field_access_token(record, field_name, timestamp)
access_token, limited_field_access_token(record, field_name, timestamp, scope=scope)
) and datetime.datetime.now() < datetime.datetime.fromtimestamp(int(timestamp, 16))
@ -1933,13 +1930,10 @@ def format_frame(frame) -> str:
def named_to_positional_printf(string: str, args: Mapping) -> tuple[str, tuple]:
""" Convert a named printf-style format string with its arguments to an
equivalent positional format string with its arguments. This implementation
does not support escaped ``%`` characters (``"%%"``).
equivalent positional format string with its arguments.
"""
if '%%' in string:
raise ValueError(f"Unsupported escaped '%' in format string {string!r}")
pargs = _PrintfArgs(args)
return string % pargs, tuple(pargs.values)
return string.replace('%%', '%%%%') % pargs, tuple(pargs.values)
class _PrintfArgs: