gooderp18绿色标准版
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

1955 rindas
68KB

  1. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  2. """
  3. Miscellaneous tools used by Odoo.
  4. """
  5. from __future__ import annotations
  6. import base64
  7. import collections
  8. import csv
  9. import datetime
  10. import enum
  11. import hashlib
  12. import hmac as hmac_lib
  13. import itertools
  14. import json
  15. import logging
  16. import os
  17. import re
  18. import sys
  19. import tempfile
  20. import threading
  21. import time
  22. import traceback
  23. import typing
  24. import unicodedata
  25. import warnings
  26. import zlib
  27. from collections import defaultdict
  28. from collections.abc import Iterable, Iterator, Mapping, MutableMapping, MutableSet, Reversible
  29. from contextlib import ContextDecorator, contextmanager
  30. from difflib import HtmlDiff
  31. from functools import reduce, wraps
  32. from itertools import islice, groupby as itergroupby
  33. from operator import itemgetter
  34. import babel
  35. import babel.dates
  36. import markupsafe
  37. import pytz
  38. from lxml import etree, objectify
  39. import odoo
  40. import odoo.addons
  41. # get_encodings, ustr and exception_to_unicode were originally from tools.misc.
  42. # There are moved to loglevels until we refactor tools.
  43. from odoo.loglevels import exception_to_unicode, get_encodings, ustr # noqa: F401
  44. from .config import config
  45. from .float_utils import float_round
  46. from .which import which
  47. K = typing.TypeVar('K')
  48. T = typing.TypeVar('T')
  49. if typing.TYPE_CHECKING:
  50. from collections.abc import Callable, Collection, Sequence
  51. from odoo.api import Environment
  52. from odoo.addons.base.models.res_lang import LangData
  53. P = typing.TypeVar('P')
  54. __all__ = [
  55. 'DEFAULT_SERVER_DATETIME_FORMAT',
  56. 'DEFAULT_SERVER_DATE_FORMAT',
  57. 'DEFAULT_SERVER_TIME_FORMAT',
  58. 'NON_BREAKING_SPACE',
  59. 'SKIPPED_ELEMENT_TYPES',
  60. 'DotDict',
  61. 'OrderedSet',
  62. 'Reverse',
  63. 'babel_locale_parse',
  64. 'clean_context',
  65. 'consteq',
  66. 'discardattr',
  67. 'exception_to_unicode',
  68. 'file_open',
  69. 'file_open_temporary_directory',
  70. 'file_path',
  71. 'find_in_path',
  72. 'formatLang',
  73. 'format_amount',
  74. 'format_date',
  75. 'format_datetime',
  76. 'format_duration',
  77. 'format_time',
  78. 'frozendict',
  79. 'get_encodings',
  80. 'get_iso_codes',
  81. 'get_lang',
  82. 'groupby',
  83. 'hmac',
  84. 'hash_sign',
  85. 'verify_hash_signed',
  86. 'html_escape',
  87. 'human_size',
  88. 'is_list_of',
  89. 'merge_sequences',
  90. 'mod10r',
  91. 'mute_logger',
  92. 'parse_date',
  93. 'partition',
  94. 'posix_to_ldml',
  95. 'remove_accents',
  96. 'replace_exceptions',
  97. 'reverse_enumerate',
  98. 'split_every',
  99. 'str2bool',
  100. 'street_split',
  101. 'topological_sort',
  102. 'unique',
  103. 'ustr',
  104. ]
  105. _logger = logging.getLogger(__name__)
  106. # List of etree._Element subclasses that we choose to ignore when parsing XML.
  107. # We include the *Base ones just in case, currently they seem to be subclasses of the _* ones.
  108. SKIPPED_ELEMENT_TYPES = (etree._Comment, etree._ProcessingInstruction, etree.CommentBase, etree.PIBase, etree._Entity)
  109. # Configure default global parser
  110. etree.set_default_parser(etree.XMLParser(resolve_entities=False))
  111. default_parser = etree.XMLParser(resolve_entities=False, remove_blank_text=True)
  112. default_parser.set_element_class_lookup(objectify.ObjectifyElementClassLookup())
  113. objectify.set_default_parser(default_parser)
  114. NON_BREAKING_SPACE = u'\N{NO-BREAK SPACE}'
  115. class Sentinel(enum.Enum):
  116. """Class for typing parameters with a sentinel as a default"""
  117. SENTINEL = -1
  118. SENTINEL = Sentinel.SENTINEL
  119. #----------------------------------------------------------
  120. # Subprocesses
  121. #----------------------------------------------------------
  122. def find_in_path(name):
  123. path = os.environ.get('PATH', os.defpath).split(os.pathsep)
  124. if config.get('bin_path') and config['bin_path'] != 'None':
  125. path.append(config['bin_path'])
  126. return which(name, path=os.pathsep.join(path))
  127. #----------------------------------------------------------
  128. # Postgres subprocesses
  129. #----------------------------------------------------------
  130. def find_pg_tool(name):
  131. path = None
  132. if config['pg_path'] and config['pg_path'] != 'None':
  133. path = config['pg_path']
  134. try:
  135. return which(name, path=path)
  136. except IOError:
  137. raise Exception('Command `%s` not found.' % name)
  138. def exec_pg_environ():
  139. """
  140. Force the database PostgreSQL environment variables to the database
  141. configuration of Odoo.
  142. Note: On systems where pg_restore/pg_dump require an explicit password
  143. (i.e. on Windows where TCP sockets are used), it is necessary to pass the
  144. postgres user password in the PGPASSWORD environment variable or in a
  145. special .pgpass file.
  146. See also http://www.postgresql.org/docs/8.4/static/libpq-envars.html
  147. """
  148. env = os.environ.copy()
  149. if odoo.tools.config['db_host']:
  150. env['PGHOST'] = odoo.tools.config['db_host']
  151. if odoo.tools.config['db_port']:
  152. env['PGPORT'] = str(odoo.tools.config['db_port'])
  153. if odoo.tools.config['db_user']:
  154. env['PGUSER'] = odoo.tools.config['db_user']
  155. if odoo.tools.config['db_password']:
  156. env['PGPASSWORD'] = odoo.tools.config['db_password']
  157. return env
  158. # ----------------------------------------------------------
  159. # File paths
  160. # ----------------------------------------------------------
  161. def file_path(file_path: str, filter_ext: tuple[str, ...] = ('',), env: Environment | None = None) -> str:
  162. """Verify that a file exists under a known `addons_path` directory and return its full path.
  163. Examples::
  164. >>> file_path('hr')
  165. >>> file_path('hr/static/description/icon.png')
  166. >>> file_path('hr/static/description/icon.png', filter_ext=('.png', '.jpg'))
  167. :param str file_path: absolute file path, or relative path within any `addons_path` directory
  168. :param list[str] filter_ext: optional list of supported extensions (lowercase, with leading dot)
  169. :param env: optional environment, required for a file path within a temporary directory
  170. created using `file_open_temporary_directory()`
  171. :return: the absolute path to the file
  172. :raise FileNotFoundError: if the file is not found under the known `addons_path` directories
  173. :raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
  174. """
  175. root_path = os.path.abspath(config['root_path'])
  176. temporary_paths = env.transaction._Transaction__file_open_tmp_paths if env else ()
  177. addons_paths = [*odoo.addons.__path__, root_path, *temporary_paths]
  178. is_abs = os.path.isabs(file_path)
  179. normalized_path = os.path.normpath(os.path.normcase(file_path))
  180. if filter_ext and not normalized_path.lower().endswith(filter_ext):
  181. raise ValueError("Unsupported file: " + file_path)
  182. # ignore leading 'addons/' if present, it's the final component of root_path, but
  183. # may sometimes be included in relative paths
  184. if normalized_path.startswith('addons' + os.sep):
  185. normalized_path = normalized_path[7:]
  186. for addons_dir in addons_paths:
  187. # final path sep required to avoid partial match
  188. parent_path = os.path.normpath(os.path.normcase(addons_dir)) + os.sep
  189. fpath = (normalized_path if is_abs else
  190. os.path.normpath(os.path.normcase(os.path.join(parent_path, normalized_path))))
  191. if fpath.startswith(parent_path) and os.path.exists(fpath):
  192. return fpath
  193. raise FileNotFoundError("File not found: " + file_path)
  194. def file_open(name: str, mode: str = "r", filter_ext: tuple[str, ...] = (), env: Environment | None = None):
  195. """Open a file from within the addons_path directories, as an absolute or relative path.
  196. Examples::
  197. >>> file_open('hr/static/description/icon.png')
  198. >>> file_open('hr/static/description/icon.png', filter_ext=('.png', '.jpg'))
  199. >>> with file_open('/opt/odoo/addons/hr/static/description/icon.png', 'rb') as f:
  200. ... contents = f.read()
  201. :param name: absolute or relative path to a file located inside an addon
  202. :param mode: file open mode, as for `open()`
  203. :param list[str] filter_ext: optional list of supported extensions (lowercase, with leading dot)
  204. :param env: optional environment, required to open a file within a temporary directory
  205. created using `file_open_temporary_directory()`
  206. :return: file object, as returned by `open()`
  207. :raise FileNotFoundError: if the file is not found under the known `addons_path` directories
  208. :raise ValueError: if the file doesn't have one of the supported extensions (`filter_ext`)
  209. """
  210. path = file_path(name, filter_ext=filter_ext, env=env)
  211. if os.path.isfile(path):
  212. if 'b' not in mode:
  213. # Force encoding for text mode, as system locale could affect default encoding,
  214. # even with the latest Python 3 versions.
  215. # Note: This is not covered by a unit test, due to the platform dependency.
  216. # For testing purposes you should be able to force a non-UTF8 encoding with:
  217. # `sudo locale-gen fr_FR; LC_ALL=fr_FR.iso8859-1 python3 ...'
  218. # See also PEP-540, although we can't rely on that at the moment.
  219. return open(path, mode, encoding="utf-8")
  220. return open(path, mode)
  221. raise FileNotFoundError("Not a file: " + name)
  222. @contextmanager
  223. def file_open_temporary_directory(env: Environment):
  224. """Create and return a temporary directory added to the directories `file_open` is allowed to read from.
  225. `file_open` will be allowed to open files within the temporary directory
  226. only for environments of the same transaction than `env`.
  227. Meaning, other transactions/requests from other users or even other databases
  228. won't be allowed to open files from this directory.
  229. Examples::
  230. >>> with odoo.tools.file_open_temporary_directory(self.env) as module_dir:
  231. ... with zipfile.ZipFile('foo.zip', 'r') as z:
  232. ... z.extract('foo/__manifest__.py', module_dir)
  233. ... with odoo.tools.file_open('foo/__manifest__.py', env=self.env) as f:
  234. ... manifest = f.read()
  235. :param env: environment for which the temporary directory is created.
  236. :return: the absolute path to the created temporary directory
  237. """
  238. assert not env.transaction._Transaction__file_open_tmp_paths, 'Reentrancy is not implemented for this method'
  239. with tempfile.TemporaryDirectory() as module_dir:
  240. try:
  241. env.transaction._Transaction__file_open_tmp_paths = (module_dir,)
  242. yield module_dir
  243. finally:
  244. env.transaction._Transaction__file_open_tmp_paths = ()
  245. #----------------------------------------------------------
  246. # iterables
  247. #----------------------------------------------------------
  248. def flatten(list):
  249. """Flatten a list of elements into a unique list
  250. Author: Christophe Simonis (christophe@tinyerp.com)
  251. Examples::
  252. >>> flatten(['a'])
  253. ['a']
  254. >>> flatten('b')
  255. ['b']
  256. >>> flatten( [] )
  257. []
  258. >>> flatten( [[], [[]]] )
  259. []
  260. >>> flatten( [[['a','b'], 'c'], 'd', ['e', [], 'f']] )
  261. ['a', 'b', 'c', 'd', 'e', 'f']
  262. >>> t = (1,2,(3,), [4, 5, [6, [7], (8, 9), ([10, 11, (12, 13)]), [14, [], (15,)], []]])
  263. >>> flatten(t)
  264. [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
  265. """
  266. warnings.warn(
  267. "deprecated since 18.0",
  268. category=DeprecationWarning,
  269. stacklevel=2,
  270. )
  271. r = []
  272. for e in list:
  273. if isinstance(e, (bytes, str)) or not isinstance(e, collections.abc.Iterable):
  274. r.append(e)
  275. else:
  276. r.extend(flatten(e))
  277. return r
  278. def reverse_enumerate(lst: Sequence[T]) -> Iterator[tuple[int, T]]:
  279. """Like enumerate but in the other direction
  280. Usage::
  281. >>> a = ['a', 'b', 'c']
  282. >>> it = reverse_enumerate(a)
  283. >>> it.next()
  284. (2, 'c')
  285. >>> it.next()
  286. (1, 'b')
  287. >>> it.next()
  288. (0, 'a')
  289. >>> it.next()
  290. Traceback (most recent call last):
  291. File "<stdin>", line 1, in <module>
  292. StopIteration
  293. """
  294. return zip(range(len(lst) - 1, -1, -1), reversed(lst))
  295. def partition(pred: Callable[[T], bool], elems: Iterable[T]) -> tuple[list[T], list[T]]:
  296. """ Return a pair equivalent to:
  297. ``filter(pred, elems), filter(lambda x: not pred(x), elems)`` """
  298. yes: list[T] = []
  299. nos: list[T] = []
  300. for elem in elems:
  301. (yes if pred(elem) else nos).append(elem)
  302. return yes, nos
  303. def topological_sort(elems: Mapping[T, Collection[T]]) -> list[T]:
  304. """ Return a list of elements sorted so that their dependencies are listed
  305. before them in the result.
  306. :param elems: specifies the elements to sort with their dependencies; it is
  307. a dictionary like `{element: dependencies}` where `dependencies` is a
  308. collection of elements that must appear before `element`. The elements
  309. of `dependencies` are not required to appear in `elems`; they will
  310. simply not appear in the result.
  311. :returns: a list with the keys of `elems` sorted according to their
  312. specification.
  313. """
  314. # the algorithm is inspired by [Tarjan 1976],
  315. # http://en.wikipedia.org/wiki/Topological_sorting#Algorithms
  316. result = []
  317. visited = set()
  318. def visit(n):
  319. if n not in visited:
  320. visited.add(n)
  321. if n in elems:
  322. # first visit all dependencies of n, then append n to result
  323. for it in elems[n]:
  324. visit(it)
  325. result.append(n)
  326. for el in elems:
  327. visit(el)
  328. return result
  329. def merge_sequences(*iterables: Iterable[T]) -> list[T]:
  330. """ Merge several iterables into a list. The result is the union of the
  331. iterables, ordered following the partial order given by the iterables,
  332. with a bias towards the end for the last iterable::
  333. seq = merge_sequences(['A', 'B', 'C'])
  334. assert seq == ['A', 'B', 'C']
  335. seq = merge_sequences(
  336. ['A', 'B', 'C'],
  337. ['Z'], # 'Z' can be anywhere
  338. ['Y', 'C'], # 'Y' must precede 'C';
  339. ['A', 'X', 'Y'], # 'X' must follow 'A' and precede 'Y'
  340. )
  341. assert seq == ['A', 'B', 'X', 'Y', 'C', 'Z']
  342. """
  343. # dict is ordered
  344. deps: defaultdict[T, list[T]] = defaultdict(list) # {item: elems_before_item}
  345. for iterable in iterables:
  346. prev: T | Sentinel = SENTINEL
  347. for item in iterable:
  348. if prev is SENTINEL:
  349. deps[item] # just set the default
  350. else:
  351. deps[item].append(prev)
  352. prev = item
  353. return topological_sort(deps)
  354. try:
  355. import xlwt
  356. # add some sanitization to respect the excel sheet name restrictions
  357. # as the sheet name is often translatable, can not control the input
  358. class PatchedWorkbook(xlwt.Workbook):
  359. def add_sheet(self, name, cell_overwrite_ok=False):
  360. # invalid Excel character: []:*?/\
  361. name = re.sub(r'[\[\]:*?/\\]', '', name)
  362. # maximum size is 31 characters
  363. name = name[:31]
  364. return super(PatchedWorkbook, self).add_sheet(name, cell_overwrite_ok=cell_overwrite_ok)
  365. xlwt.Workbook = PatchedWorkbook
  366. except ImportError:
  367. xlwt = None
  368. try:
  369. import xlsxwriter
  370. # add some sanitization to respect the excel sheet name restrictions
  371. # as the sheet name is often translatable, can not control the input
  372. class PatchedXlsxWorkbook(xlsxwriter.Workbook):
  373. # TODO when xlsxwriter bump to 0.9.8, add worksheet_class=None parameter instead of kw
  374. def add_worksheet(self, name=None, **kw):
  375. if name:
  376. # invalid Excel character: []:*?/\
  377. name = re.sub(r'[\[\]:*?/\\]', '', name)
  378. # maximum size is 31 characters
  379. name = name[:31]
  380. return super(PatchedXlsxWorkbook, self).add_worksheet(name, **kw)
  381. xlsxwriter.Workbook = PatchedXlsxWorkbook
  382. except ImportError:
  383. xlsxwriter = None
  384. def get_iso_codes(lang: str) -> str:
  385. if lang.find('_') != -1:
  386. if lang.split('_')[0] == lang.split('_')[1].lower():
  387. lang = lang.split('_')[0]
  388. return lang
  389. def scan_languages() -> list[tuple[str, str]]:
  390. """ Returns all languages supported by OpenERP for translation
  391. :returns: a list of (lang_code, lang_name) pairs
  392. :rtype: [(str, unicode)]
  393. """
  394. try:
  395. # read (code, name) from languages in base/data/res.lang.csv
  396. with file_open('base/data/res.lang.csv') as csvfile:
  397. reader = csv.reader(csvfile, delimiter=',', quotechar='"')
  398. fields = next(reader)
  399. code_index = fields.index("code")
  400. name_index = fields.index("name")
  401. result = [
  402. (row[code_index], row[name_index])
  403. for row in reader
  404. ]
  405. except Exception:
  406. _logger.error("Could not read res.lang.csv")
  407. result = []
  408. return sorted(result or [('en_US', u'English')], key=itemgetter(1))
  409. def mod10r(number: str) -> str:
  410. """
  411. Input number : account or invoice number
  412. Output return: the same number completed with the recursive mod10
  413. key
  414. """
  415. codec=[0,9,4,6,8,2,7,1,3,5]
  416. report = 0
  417. result=""
  418. for digit in number:
  419. result += digit
  420. if digit.isdigit():
  421. report = codec[ (int(digit) + report) % 10 ]
  422. return result + str((10 - report) % 10)
  423. def str2bool(s: str, default: bool | None = None) -> bool:
  424. # allow this (for now?) because it's used for get_param
  425. if type(s) is bool:
  426. return s # type: ignore
  427. if not isinstance(s, str):
  428. warnings.warn(
  429. f"Passed a non-str to `str2bool`: {s}",
  430. DeprecationWarning,
  431. stacklevel=2,
  432. )
  433. if default is None:
  434. raise ValueError('Use 0/1/yes/no/true/false/on/off')
  435. return bool(default)
  436. s = s.lower()
  437. if s in ('y', 'yes', '1', 'true', 't', 'on'):
  438. return True
  439. if s in ('n', 'no', '0', 'false', 'f', 'off'):
  440. return False
  441. if default is None:
  442. raise ValueError('Use 0/1/yes/no/true/false/on/off')
  443. return bool(default)
  444. def human_size(sz: float | str) -> str | typing.Literal[False]:
  445. """
  446. Return the size in a human readable format
  447. """
  448. if not sz:
  449. return False
  450. units = ('bytes', 'Kb', 'Mb', 'Gb', 'Tb')
  451. if isinstance(sz, str):
  452. sz=len(sz)
  453. s, i = float(sz), 0
  454. while s >= 1024 and i < len(units)-1:
  455. s /= 1024
  456. i += 1
  457. return "%0.2f %s" % (s, units[i])
  458. DEFAULT_SERVER_DATE_FORMAT = "%Y-%m-%d"
  459. DEFAULT_SERVER_TIME_FORMAT = "%H:%M:%S"
  460. DEFAULT_SERVER_DATETIME_FORMAT = "%s %s" % (
  461. DEFAULT_SERVER_DATE_FORMAT,
  462. DEFAULT_SERVER_TIME_FORMAT)
  463. DATE_LENGTH = len(datetime.date.today().strftime(DEFAULT_SERVER_DATE_FORMAT))
  464. # Python's strftime supports only the format directives
  465. # that are available on the platform's libc, so in order to
  466. # be cross-platform we map to the directives required by
  467. # the C standard (1989 version), always available on platforms
  468. # with a C standard implementation.
  469. DATETIME_FORMATS_MAP = {
  470. '%C': '', # century
  471. '%D': '%m/%d/%Y', # modified %y->%Y
  472. '%e': '%d',
  473. '%E': '', # special modifier
  474. '%F': '%Y-%m-%d',
  475. '%g': '%Y', # modified %y->%Y
  476. '%G': '%Y',
  477. '%h': '%b',
  478. '%k': '%H',
  479. '%l': '%I',
  480. '%n': '\n',
  481. '%O': '', # special modifier
  482. '%P': '%p',
  483. '%R': '%H:%M',
  484. '%r': '%I:%M:%S %p',
  485. '%s': '', #num of seconds since epoch
  486. '%T': '%H:%M:%S',
  487. '%t': ' ', # tab
  488. '%u': ' %w',
  489. '%V': '%W',
  490. '%y': '%Y', # Even if %y works, it's ambiguous, so we should use %Y
  491. '%+': '%Y-%m-%d %H:%M:%S',
  492. # %Z is a special case that causes 2 problems at least:
  493. # - the timezone names we use (in res_user.context_tz) come
  494. # from pytz, but not all these names are recognized by
  495. # strptime(), so we cannot convert in both directions
  496. # when such a timezone is selected and %Z is in the format
  497. # - %Z is replaced by an empty string in strftime() when
  498. # there is not tzinfo in a datetime value (e.g when the user
  499. # did not pick a context_tz). The resulting string does not
  500. # parse back if the format requires %Z.
  501. # As a consequence, we strip it completely from format strings.
  502. # The user can always have a look at the context_tz in
  503. # preferences to check the timezone.
  504. '%z': '',
  505. '%Z': '',
  506. }
  507. POSIX_TO_LDML = {
  508. 'a': 'E',
  509. 'A': 'EEEE',
  510. 'b': 'MMM',
  511. 'B': 'MMMM',
  512. #'c': '',
  513. 'd': 'dd',
  514. 'H': 'HH',
  515. 'I': 'hh',
  516. 'j': 'DDD',
  517. 'm': 'MM',
  518. 'M': 'mm',
  519. 'p': 'a',
  520. 'S': 'ss',
  521. 'U': 'w',
  522. 'w': 'e',
  523. 'W': 'w',
  524. 'y': 'yy',
  525. 'Y': 'yyyy',
  526. # see comments above, and babel's format_datetime assumes an UTC timezone
  527. # for naive datetime objects
  528. #'z': 'Z',
  529. #'Z': 'z',
  530. }
  531. def posix_to_ldml(fmt: str, locale: babel.Locale) -> str:
  532. """ Converts a posix/strftime pattern into an LDML date format pattern.
  533. :param fmt: non-extended C89/C90 strftime pattern
  534. :param locale: babel locale used for locale-specific conversions (e.g. %x and %X)
  535. :return: unicode
  536. """
  537. buf = []
  538. pc = False
  539. quoted = []
  540. for c in fmt:
  541. # LDML date format patterns uses letters, so letters must be quoted
  542. if not pc and c.isalpha():
  543. quoted.append(c if c != "'" else "''")
  544. continue
  545. if quoted:
  546. buf.append("'")
  547. buf.append(''.join(quoted))
  548. buf.append("'")
  549. quoted = []
  550. if pc:
  551. if c == '%': # escaped percent
  552. buf.append('%')
  553. elif c == 'x': # date format, short seems to match
  554. buf.append(locale.date_formats['short'].pattern)
  555. elif c == 'X': # time format, seems to include seconds. short does not
  556. buf.append(locale.time_formats['medium'].pattern)
  557. else: # look up format char in static mapping
  558. buf.append(POSIX_TO_LDML[c])
  559. pc = False
  560. elif c == '%':
  561. pc = True
  562. else:
  563. buf.append(c)
  564. # flush anything remaining in quoted buffer
  565. if quoted:
  566. buf.append("'")
  567. buf.append(''.join(quoted))
  568. buf.append("'")
  569. return ''.join(buf)
  570. @typing.overload
  571. def split_every(n: int, iterable: Iterable[T]) -> Iterator[tuple[T, ...]]:
  572. ...
  573. @typing.overload
  574. def split_every(n: int, iterable: Iterable[T], piece_maker: type[Collection[T]]) -> Iterator[Collection[T]]:
  575. ...
  576. @typing.overload
  577. def split_every(n: int, iterable: Iterable[T], piece_maker: Callable[[Iterable[T]], P]) -> Iterator[P]:
  578. ...
  579. def split_every(n: int, iterable: Iterable[T], piece_maker=tuple):
  580. """Splits an iterable into length-n pieces. The last piece will be shorter
  581. if ``n`` does not evenly divide the iterable length.
  582. :param int n: maximum size of each generated chunk
  583. :param Iterable iterable: iterable to chunk into pieces
  584. :param piece_maker: callable taking an iterable and collecting each
  585. chunk from its slice, *must consume the entire slice*.
  586. """
  587. iterator = iter(iterable)
  588. piece = piece_maker(islice(iterator, n))
  589. while piece:
  590. yield piece
  591. piece = piece_maker(islice(iterator, n))
  592. def discardattr(obj: object, key: str) -> None:
  593. """ Perform a ``delattr(obj, key)`` but without crashing if ``key`` is not present. """
  594. try:
  595. delattr(obj, key)
  596. except AttributeError:
  597. pass
  598. # ---------------------------------------------
  599. # String management
  600. # ---------------------------------------------
  601. # Inspired by http://stackoverflow.com/questions/517923
  602. def remove_accents(input_str: str) -> str:
  603. """Suboptimal-but-better-than-nothing way to replace accented
  604. latin letters by an ASCII equivalent. Will obviously change the
  605. meaning of input_str and work only for some cases"""
  606. if not input_str:
  607. return input_str
  608. nkfd_form = unicodedata.normalize('NFKD', input_str)
  609. return ''.join(c for c in nkfd_form if not unicodedata.combining(c))
  610. class unquote(str):
  611. """A subclass of str that implements repr() without enclosing quotation marks
  612. or escaping, keeping the original string untouched. The name come from Lisp's unquote.
  613. One of the uses for this is to preserve or insert bare variable names within dicts during eval()
  614. of a dict's repr(). Use with care.
  615. Some examples (notice that there are never quotes surrounding
  616. the ``active_id`` name:
  617. >>> unquote('active_id')
  618. active_id
  619. >>> d = {'test': unquote('active_id')}
  620. >>> d
  621. {'test': active_id}
  622. >>> print d
  623. {'test': active_id}
  624. """
  625. __slots__ = ()
  626. def __repr__(self):
  627. return self
  628. class mute_logger(logging.Handler):
  629. """Temporary suppress the logging.
  630. Can be used as context manager or decorator::
  631. @mute_logger('odoo.plic.ploc')
  632. def do_stuff():
  633. blahblah()
  634. with mute_logger('odoo.foo.bar'):
  635. do_suff()
  636. """
  637. def __init__(self, *loggers):
  638. super().__init__()
  639. self.loggers = loggers
  640. self.old_params = {}
  641. def __enter__(self):
  642. for logger_name in self.loggers:
  643. logger = logging.getLogger(logger_name)
  644. self.old_params[logger_name] = (logger.handlers, logger.propagate)
  645. logger.propagate = False
  646. logger.handlers = [self]
  647. def __exit__(self, exc_type=None, exc_val=None, exc_tb=None):
  648. for logger_name in self.loggers:
  649. logger = logging.getLogger(logger_name)
  650. logger.handlers, logger.propagate = self.old_params[logger_name]
  651. def __call__(self, func):
  652. @wraps(func)
  653. def deco(*args, **kwargs):
  654. with self:
  655. return func(*args, **kwargs)
  656. return deco
  657. def emit(self, record):
  658. pass
  659. class lower_logging(logging.Handler):
  660. """Temporary lower the max logging level.
  661. """
  662. def __init__(self, max_level, to_level=None):
  663. super().__init__()
  664. self.old_handlers = None
  665. self.old_propagate = None
  666. self.had_error_log = False
  667. self.max_level = max_level
  668. self.to_level = to_level or max_level
  669. def __enter__(self):
  670. logger = logging.getLogger()
  671. self.old_handlers = logger.handlers[:]
  672. self.old_propagate = logger.propagate
  673. logger.propagate = False
  674. logger.handlers = [self]
  675. self.had_error_log = False
  676. return self
  677. def __exit__(self, exc_type=None, exc_val=None, exc_tb=None):
  678. logger = logging.getLogger()
  679. logger.handlers = self.old_handlers
  680. logger.propagate = self.old_propagate
  681. def emit(self, record):
  682. if record.levelno > self.max_level:
  683. record.levelname = f'_{record.levelname}'
  684. record.levelno = self.to_level
  685. self.had_error_log = True
  686. record.args = tuple(arg.replace('Traceback (most recent call last):', '_Traceback_ (most recent call last):') if isinstance(arg, str) else arg for arg in record.args)
  687. if logging.getLogger(record.name).isEnabledFor(record.levelno):
  688. for handler in self.old_handlers:
  689. if handler.level <= record.levelno:
  690. handler.emit(record)
  691. def stripped_sys_argv(*strip_args):
  692. """Return sys.argv with some arguments stripped, suitable for reexecution or subprocesses"""
  693. strip_args = sorted(set(strip_args) | set(['-s', '--save', '-u', '--update', '-i', '--init', '--i18n-overwrite']))
  694. assert all(config.parser.has_option(s) for s in strip_args)
  695. takes_value = dict((s, config.parser.get_option(s).takes_value()) for s in strip_args)
  696. longs, shorts = list(tuple(y) for _, y in itergroupby(strip_args, lambda x: x.startswith('--')))
  697. longs_eq = tuple(l + '=' for l in longs if takes_value[l])
  698. args = sys.argv[:]
  699. def strip(args, i):
  700. return args[i].startswith(shorts) \
  701. or args[i].startswith(longs_eq) or (args[i] in longs) \
  702. or (i >= 1 and (args[i - 1] in strip_args) and takes_value[args[i - 1]])
  703. return [x for i, x in enumerate(args) if not strip(args, i)]
  704. class ConstantMapping(Mapping[typing.Any, T], typing.Generic[T]):
  705. """
  706. An immutable mapping returning the provided value for every single key.
  707. Useful for default value to methods
  708. """
  709. __slots__ = ['_value']
  710. def __init__(self, val: T):
  711. self._value = val
  712. def __len__(self):
  713. """
  714. defaultdict updates its length for each individually requested key, is
  715. that really useful?
  716. """
  717. return 0
  718. def __iter__(self):
  719. """
  720. same as len, defaultdict updates its iterable keyset with each key
  721. requested, is there a point for this?
  722. """
  723. return iter([])
  724. def __getitem__(self, item) -> T:
  725. return self._value
  726. def dumpstacks(sig=None, frame=None, thread_idents=None, log_level=logging.INFO):
  727. """ Signal handler: dump a stack trace for each existing thread or given
  728. thread(s) specified through the ``thread_idents`` sequence.
  729. """
  730. code = []
  731. def extract_stack(stack):
  732. for filename, lineno, name, line in traceback.extract_stack(stack):
  733. yield 'File: "%s", line %d, in %s' % (filename, lineno, name)
  734. if line:
  735. yield " %s" % (line.strip(),)
  736. # code from http://stackoverflow.com/questions/132058/getting-stack-trace-from-a-running-python-application#answer-2569696
  737. # modified for python 2.5 compatibility
  738. threads_info = {th.ident: {'repr': repr(th),
  739. 'uid': getattr(th, 'uid', 'n/a'),
  740. 'dbname': getattr(th, 'dbname', 'n/a'),
  741. 'url': getattr(th, 'url', 'n/a'),
  742. 'query_count': getattr(th, 'query_count', 'n/a'),
  743. 'query_time': getattr(th, 'query_time', None),
  744. 'perf_t0': getattr(th, 'perf_t0', None)}
  745. for th in threading.enumerate()}
  746. for threadId, stack in sys._current_frames().items():
  747. if not thread_idents or threadId in thread_idents:
  748. thread_info = threads_info.get(threadId, {})
  749. query_time = thread_info.get('query_time')
  750. perf_t0 = thread_info.get('perf_t0')
  751. remaining_time = None
  752. if query_time is not None and perf_t0:
  753. remaining_time = '%.3f' % (time.time() - perf_t0 - query_time)
  754. query_time = '%.3f' % query_time
  755. # qc:query_count qt:query_time pt:python_time (aka remaining time)
  756. code.append("\n# Thread: %s (db:%s) (uid:%s) (url:%s) (qc:%s qt:%s pt:%s)" %
  757. (thread_info.get('repr', threadId),
  758. thread_info.get('dbname', 'n/a'),
  759. thread_info.get('uid', 'n/a'),
  760. thread_info.get('url', 'n/a'),
  761. thread_info.get('query_count', 'n/a'),
  762. query_time or 'n/a',
  763. remaining_time or 'n/a'))
  764. for line in extract_stack(stack):
  765. code.append(line)
  766. if odoo.evented:
  767. # code from http://stackoverflow.com/questions/12510648/in-gevent-how-can-i-dump-stack-traces-of-all-running-greenlets
  768. import gc
  769. from greenlet import greenlet
  770. for ob in gc.get_objects():
  771. if not isinstance(ob, greenlet) or not ob:
  772. continue
  773. code.append("\n# Greenlet: %r" % (ob,))
  774. for line in extract_stack(ob.gr_frame):
  775. code.append(line)
  776. _logger.log(log_level, "\n".join(code))
  777. def freehash(arg: typing.Any) -> int:
  778. try:
  779. return hash(arg)
  780. except Exception:
  781. if isinstance(arg, Mapping):
  782. return hash(frozendict(arg))
  783. elif isinstance(arg, Iterable):
  784. return hash(frozenset(freehash(item) for item in arg))
  785. else:
  786. return id(arg)
  787. def clean_context(context: dict[str, typing.Any]) -> dict[str, typing.Any]:
  788. """ This function take a dictionary and remove each entry with its key
  789. starting with ``default_``
  790. """
  791. return {k: v for k, v in context.items() if not k.startswith('default_')}
  792. class frozendict(dict[K, T], typing.Generic[K, T]):
  793. """ An implementation of an immutable dictionary. """
  794. __slots__ = ()
  795. def __delitem__(self, key):
  796. raise NotImplementedError("'__delitem__' not supported on frozendict")
  797. def __setitem__(self, key, val):
  798. raise NotImplementedError("'__setitem__' not supported on frozendict")
  799. def clear(self):
  800. raise NotImplementedError("'clear' not supported on frozendict")
  801. def pop(self, key, default=None):
  802. raise NotImplementedError("'pop' not supported on frozendict")
  803. def popitem(self):
  804. raise NotImplementedError("'popitem' not supported on frozendict")
  805. def setdefault(self, key, default=None):
  806. raise NotImplementedError("'setdefault' not supported on frozendict")
  807. def update(self, *args, **kwargs):
  808. raise NotImplementedError("'update' not supported on frozendict")
  809. def __hash__(self) -> int: # type: ignore
  810. return hash(frozenset((key, freehash(val)) for key, val in self.items()))
  811. class Collector(dict[K, tuple[T, ...]], typing.Generic[K, T]):
  812. """ A mapping from keys to tuples. This implements a relation, and can be
  813. seen as a space optimization for ``defaultdict(tuple)``.
  814. """
  815. __slots__ = ()
  816. def __getitem__(self, key: K) -> tuple[T, ...]:
  817. return self.get(key, ())
  818. def __setitem__(self, key: K, val: Iterable[T]):
  819. val = tuple(val)
  820. if val:
  821. super().__setitem__(key, val)
  822. else:
  823. super().pop(key, None)
  824. def add(self, key: K, val: T):
  825. vals = self[key]
  826. if val not in vals:
  827. self[key] = vals + (val,)
  828. def discard_keys_and_values(self, excludes: Collection[K | T]) -> None:
  829. for key in excludes:
  830. self.pop(key, None) # type: ignore
  831. for key, vals in list(self.items()):
  832. self[key] = tuple(val for val in vals if val not in excludes) # type: ignore
  833. class StackMap(MutableMapping[K, T], typing.Generic[K, T]):
  834. """ A stack of mappings behaving as a single mapping, and used to implement
  835. nested scopes. The lookups search the stack from top to bottom, and
  836. returns the first value found. Mutable operations modify the topmost
  837. mapping only.
  838. """
  839. __slots__ = ['_maps']
  840. def __init__(self, m: MutableMapping[K, T] | None = None):
  841. self._maps = [] if m is None else [m]
  842. def __getitem__(self, key: K) -> T:
  843. for mapping in reversed(self._maps):
  844. try:
  845. return mapping[key]
  846. except KeyError:
  847. pass
  848. raise KeyError(key)
  849. def __setitem__(self, key: K, val: T):
  850. self._maps[-1][key] = val
  851. def __delitem__(self, key: K):
  852. del self._maps[-1][key]
  853. def __iter__(self) -> Iterator[K]:
  854. return iter({key for mapping in self._maps for key in mapping})
  855. def __len__(self) -> int:
  856. return sum(1 for key in self)
  857. def __str__(self) -> str:
  858. return f"<StackMap {self._maps}>"
  859. def pushmap(self, m: MutableMapping[K, T] | None = None):
  860. self._maps.append({} if m is None else m)
  861. def popmap(self) -> MutableMapping[K, T]:
  862. return self._maps.pop()
  863. class OrderedSet(MutableSet[T], typing.Generic[T]):
  864. """ A set collection that remembers the elements first insertion order. """
  865. __slots__ = ['_map']
  866. def __init__(self, elems=()):
  867. self._map: dict[T, None] = dict.fromkeys(elems)
  868. def __contains__(self, elem):
  869. return elem in self._map
  870. def __iter__(self):
  871. return iter(self._map)
  872. def __len__(self):
  873. return len(self._map)
  874. def add(self, elem):
  875. self._map[elem] = None
  876. def discard(self, elem):
  877. self._map.pop(elem, None)
  878. def update(self, elems):
  879. self._map.update(zip(elems, itertools.repeat(None)))
  880. def difference_update(self, elems):
  881. for elem in elems:
  882. self.discard(elem)
  883. def __repr__(self):
  884. return f'{type(self).__name__}({list(self)!r})'
  885. def intersection(self, *others):
  886. return reduce(OrderedSet.__and__, others, self)
  887. class LastOrderedSet(OrderedSet[T], typing.Generic[T]):
  888. """ A set collection that remembers the elements last insertion order. """
  889. def add(self, elem):
  890. self.discard(elem)
  891. super().add(elem)
  892. class Callbacks:
  893. """ A simple queue of callback functions. Upon run, every function is
  894. called (in addition order), and the queue is emptied.
  895. ::
  896. callbacks = Callbacks()
  897. # add foo
  898. def foo():
  899. print("foo")
  900. callbacks.add(foo)
  901. # add bar
  902. callbacks.add
  903. def bar():
  904. print("bar")
  905. # add foo again
  906. callbacks.add(foo)
  907. # call foo(), bar(), foo(), then clear the callback queue
  908. callbacks.run()
  909. The queue also provides a ``data`` dictionary, that may be freely used to
  910. store anything, but is mostly aimed at aggregating data for callbacks. The
  911. dictionary is automatically cleared by ``run()`` once all callback functions
  912. have been called.
  913. ::
  914. # register foo to process aggregated data
  915. @callbacks.add
  916. def foo():
  917. print(sum(callbacks.data['foo']))
  918. callbacks.data.setdefault('foo', []).append(1)
  919. ...
  920. callbacks.data.setdefault('foo', []).append(2)
  921. ...
  922. callbacks.data.setdefault('foo', []).append(3)
  923. # call foo(), which prints 6
  924. callbacks.run()
  925. Given the global nature of ``data``, the keys should identify in a unique
  926. way the data being stored. It is recommended to use strings with a
  927. structure like ``"{module}.{feature}"``.
  928. """
  929. __slots__ = ['_funcs', 'data']
  930. def __init__(self):
  931. self._funcs: collections.deque[Callable] = collections.deque()
  932. self.data = {}
  933. def add(self, func: Callable) -> None:
  934. """ Add the given function. """
  935. self._funcs.append(func)
  936. def run(self) -> None:
  937. """ Call all the functions (in addition order), then clear associated data.
  938. """
  939. while self._funcs:
  940. func = self._funcs.popleft()
  941. func()
  942. self.clear()
  943. def clear(self) -> None:
  944. """ Remove all callbacks and data from self. """
  945. self._funcs.clear()
  946. self.data.clear()
  947. class ReversedIterable(Reversible[T], typing.Generic[T]):
  948. """ An iterable implementing the reversal of another iterable. """
  949. __slots__ = ['iterable']
  950. def __init__(self, iterable: Reversible[T]):
  951. self.iterable = iterable
  952. def __iter__(self):
  953. return reversed(self.iterable)
  954. def __reversed__(self):
  955. return iter(self.iterable)
  956. def groupby(iterable: Iterable[T], key: Callable[[T], K] = lambda arg: arg) -> Iterable[tuple[K, list[T]]]:
  957. """ Return a collection of pairs ``(key, elements)`` from ``iterable``. The
  958. ``key`` is a function computing a key value for each element. This
  959. function is similar to ``itertools.groupby``, but aggregates all
  960. elements under the same key, not only consecutive elements.
  961. """
  962. groups = defaultdict(list)
  963. for elem in iterable:
  964. groups[key(elem)].append(elem)
  965. return groups.items()
  966. def unique(it: Iterable[T]) -> Iterator[T]:
  967. """ "Uniquifier" for the provided iterable: will output each element of
  968. the iterable once.
  969. The iterable's elements must be hashahble.
  970. :param Iterable it:
  971. :rtype: Iterator
  972. """
  973. seen = set()
  974. for e in it:
  975. if e not in seen:
  976. seen.add(e)
  977. yield e
  978. def submap(mapping: Mapping[K, T], keys: Iterable[K]) -> Mapping[K, T]:
  979. """
  980. Get a filtered copy of the mapping where only some keys are present.
  981. :param Mapping mapping: the original dict-like structure to filter
  982. :param Iterable keys: the list of keys to keep
  983. :return dict: a filtered dict copy of the original mapping
  984. """
  985. keys = frozenset(keys)
  986. return {key: mapping[key] for key in mapping if key in keys}
  987. class Reverse(object):
  988. """ Wraps a value and reverses its ordering, useful in key functions when
  989. mixing ascending and descending sort on non-numeric data as the
  990. ``reverse`` parameter can not do piecemeal reordering.
  991. """
  992. __slots__ = ['val']
  993. def __init__(self, val):
  994. self.val = val
  995. def __eq__(self, other): return self.val == other.val
  996. def __ne__(self, other): return self.val != other.val
  997. def __ge__(self, other): return self.val <= other.val
  998. def __gt__(self, other): return self.val < other.val
  999. def __le__(self, other): return self.val >= other.val
  1000. def __lt__(self, other): return self.val > other.val
  1001. class replace_exceptions(ContextDecorator):
  1002. """
  1003. Hide some exceptions behind another error. Can be used as a function
  1004. decorator or as a context manager.
  1005. .. code-block:
  1006. @route('/super/secret/route', auth='public')
  1007. @replace_exceptions(AccessError, by=NotFound())
  1008. def super_secret_route(self):
  1009. if not request.session.uid:
  1010. raise AccessError("Route hidden to non logged-in users")
  1011. ...
  1012. def some_util():
  1013. ...
  1014. with replace_exceptions(ValueError, by=UserError("Invalid argument")):
  1015. ...
  1016. ...
  1017. :param exceptions: the exception classes to catch and replace.
  1018. :param by: the exception to raise instead.
  1019. """
  1020. def __init__(self, *exceptions, by):
  1021. if not exceptions:
  1022. raise ValueError("Missing exceptions")
  1023. wrong_exc = next((exc for exc in exceptions if not issubclass(exc, Exception)), None)
  1024. if wrong_exc:
  1025. raise TypeError(f"{wrong_exc} is not an exception class.")
  1026. self.exceptions = exceptions
  1027. self.by = by
  1028. def __enter__(self):
  1029. return self
  1030. def __exit__(self, exc_type, exc_value, traceback):
  1031. if exc_type is not None and issubclass(exc_type, self.exceptions):
  1032. if isinstance(self.by, type) and exc_value.args:
  1033. # copy the message
  1034. raise self.by(exc_value.args[0]) from exc_value
  1035. else:
  1036. raise self.by from exc_value
  1037. html_escape = markupsafe.escape
  1038. def get_lang(env: Environment, lang_code: str | None = None) -> LangData:
  1039. """
  1040. Retrieve the first lang object installed, by checking the parameter lang_code,
  1041. the context and then the company. If no lang is installed from those variables,
  1042. fallback on english or on the first lang installed in the system.
  1043. :param env:
  1044. :param str lang_code: the locale (i.e. en_US)
  1045. :return LangData: the first lang found that is installed on the system.
  1046. """
  1047. langs = [code for code, _ in env['res.lang'].get_installed()]
  1048. lang = 'en_US' if 'en_US' in langs else langs[0]
  1049. if lang_code and lang_code in langs:
  1050. lang = lang_code
  1051. elif (context_lang := env.context.get('lang')) in langs:
  1052. lang = context_lang
  1053. elif (company_lang := env.user.with_context(lang='en_US').company_id.partner_id.lang) in langs:
  1054. lang = company_lang
  1055. return env['res.lang']._get_data(code=lang)
  1056. def babel_locale_parse(lang_code: str | None) -> babel.Locale:
  1057. if lang_code:
  1058. try:
  1059. return babel.Locale.parse(lang_code)
  1060. except Exception: # noqa: BLE001
  1061. pass
  1062. try:
  1063. return babel.Locale.default()
  1064. except Exception: # noqa: BLE001
  1065. return babel.Locale.parse("en_US")
  1066. def formatLang(
  1067. env: Environment,
  1068. value: float | typing.Literal[''],
  1069. digits: int = 2,
  1070. grouping: bool = True,
  1071. monetary: bool | Sentinel = SENTINEL,
  1072. dp: str | None = None,
  1073. currency_obj=None,
  1074. rounding_method: typing.Literal['HALF-UP', 'HALF-DOWN', 'HALF-EVEN', "UP", "DOWN"] = 'HALF-EVEN',
  1075. rounding_unit: typing.Literal['decimals', 'units', 'thousands', 'lakhs', 'millions'] = 'decimals',
  1076. ) -> str:
  1077. """
  1078. This function will format a number `value` to the appropriate format of the language used.
  1079. :param Object env: The environment.
  1080. :param float value: The value to be formatted.
  1081. :param int digits: The number of decimals digits.
  1082. :param bool grouping: Usage of language grouping or not.
  1083. :param bool monetary: Usage of thousands separator or not.
  1084. .. deprecated:: 13.0
  1085. :param str dp: Name of the decimals precision to be used. This will override ``digits``
  1086. and ``currency_obj`` precision.
  1087. :param Object currency_obj: Currency to be used. This will override ``digits`` precision.
  1088. :param str rounding_method: The rounding method to be used:
  1089. **'HALF-UP'** will round to the closest number with ties going away from zero,
  1090. **'HALF-DOWN'** will round to the closest number with ties going towards zero,
  1091. **'HALF_EVEN'** will round to the closest number with ties going to the closest
  1092. even number,
  1093. **'UP'** will always round away from 0,
  1094. **'DOWN'** will always round towards 0.
  1095. :param str rounding_unit: The rounding unit to be used:
  1096. **decimals** will round to decimals with ``digits`` or ``dp`` precision,
  1097. **units** will round to units without any decimals,
  1098. **thousands** will round to thousands without any decimals,
  1099. **lakhs** will round to lakhs without any decimals,
  1100. **millions** will round to millions without any decimals.
  1101. :returns: The value formatted.
  1102. :rtype: str
  1103. """
  1104. if monetary is not SENTINEL:
  1105. warnings.warn("monetary argument deprecated since 13.0", DeprecationWarning, 2)
  1106. # We don't want to return 0
  1107. if value == '':
  1108. return ''
  1109. if rounding_unit == 'decimals':
  1110. if dp:
  1111. digits = env['decimal.precision'].precision_get(dp)
  1112. elif currency_obj:
  1113. digits = currency_obj.decimal_places
  1114. else:
  1115. digits = 0
  1116. rounding_unit_mapping = {
  1117. 'decimals': 1,
  1118. 'thousands': 10**3,
  1119. 'lakhs': 10**5,
  1120. 'millions': 10**6,
  1121. 'units': 1,
  1122. }
  1123. value /= rounding_unit_mapping[rounding_unit]
  1124. rounded_value = float_round(value, precision_digits=digits, rounding_method=rounding_method)
  1125. lang = env['res.lang'].browse(get_lang(env).id)
  1126. formatted_value = lang.format(f'%.{digits}f', rounded_value, grouping=grouping)
  1127. if currency_obj and currency_obj.symbol:
  1128. arguments = (formatted_value, NON_BREAKING_SPACE, currency_obj.symbol)
  1129. return '%s%s%s' % (arguments if currency_obj.position == 'after' else arguments[::-1])
  1130. return formatted_value
  1131. def format_date(
  1132. env: Environment,
  1133. value: datetime.datetime | datetime.date | str,
  1134. lang_code: str | None = None,
  1135. date_format: str | typing.Literal[False] = False,
  1136. ) -> str:
  1137. """
  1138. Formats the date in a given format.
  1139. :param env: an environment.
  1140. :param date, datetime or string value: the date to format.
  1141. :param string lang_code: the lang code, if not specified it is extracted from the
  1142. environment context.
  1143. :param string date_format: the format or the date (LDML format), if not specified the
  1144. default format of the lang.
  1145. :return: date formatted in the specified format.
  1146. :rtype: string
  1147. """
  1148. if not value:
  1149. return ''
  1150. if isinstance(value, str):
  1151. if len(value) < DATE_LENGTH:
  1152. return ''
  1153. if len(value) > DATE_LENGTH:
  1154. # a datetime, convert to correct timezone
  1155. value = odoo.fields.Datetime.from_string(value)
  1156. value = odoo.fields.Datetime.context_timestamp(env['res.lang'], value)
  1157. else:
  1158. value = odoo.fields.Datetime.from_string(value)
  1159. elif isinstance(value, datetime.datetime) and not value.tzinfo:
  1160. # a datetime, convert to correct timezone
  1161. value = odoo.fields.Datetime.context_timestamp(env['res.lang'], value)
  1162. lang = get_lang(env, lang_code)
  1163. locale = babel_locale_parse(lang.code)
  1164. if not date_format:
  1165. date_format = posix_to_ldml(lang.date_format, locale=locale)
  1166. assert isinstance(value, datetime.date) # datetime is a subclass of date
  1167. return babel.dates.format_date(value, format=date_format, locale=locale)
  1168. def parse_date(env: Environment, value: str, lang_code: str | None = None) -> datetime.date | str:
  1169. """
  1170. Parse the date from a given format. If it is not a valid format for the
  1171. localization, return the original string.
  1172. :param env: an environment.
  1173. :param string value: the date to parse.
  1174. :param string lang_code: the lang code, if not specified it is extracted from the
  1175. environment context.
  1176. :return: date object from the localized string
  1177. :rtype: datetime.date
  1178. """
  1179. lang = get_lang(env, lang_code)
  1180. locale = babel_locale_parse(lang.code)
  1181. try:
  1182. return babel.dates.parse_date(value, locale=locale)
  1183. except:
  1184. return value
  1185. def format_datetime(
  1186. env: Environment,
  1187. value: datetime.datetime | str,
  1188. tz: str | typing.Literal[False] = False,
  1189. dt_format: str = 'medium',
  1190. lang_code: str | None = None,
  1191. ) -> str:
  1192. """ Formats the datetime in a given format.
  1193. :param env:
  1194. :param str|datetime value: naive datetime to format either in string or in datetime
  1195. :param str tz: name of the timezone in which the given datetime should be localized
  1196. :param str dt_format: one of “full”, “long”, “medium”, or “short”, or a custom date/time pattern compatible with `babel` lib
  1197. :param str lang_code: ISO code of the language to use to render the given datetime
  1198. :rtype: str
  1199. """
  1200. if not value:
  1201. return ''
  1202. if isinstance(value, str):
  1203. timestamp = odoo.fields.Datetime.from_string(value)
  1204. else:
  1205. timestamp = value
  1206. tz_name = tz or env.user.tz or 'UTC'
  1207. utc_datetime = pytz.utc.localize(timestamp, is_dst=False)
  1208. try:
  1209. context_tz = pytz.timezone(tz_name)
  1210. localized_datetime = utc_datetime.astimezone(context_tz)
  1211. except Exception:
  1212. localized_datetime = utc_datetime
  1213. lang = get_lang(env, lang_code)
  1214. locale = babel_locale_parse(lang.code or lang_code) # lang can be inactive, so `lang`is empty
  1215. if not dt_format:
  1216. date_format = posix_to_ldml(lang.date_format, locale=locale)
  1217. time_format = posix_to_ldml(lang.time_format, locale=locale)
  1218. dt_format = '%s %s' % (date_format, time_format)
  1219. # Babel allows to format datetime in a specific language without change locale
  1220. # So month 1 = January in English, and janvier in French
  1221. # Be aware that the default value for format is 'medium', instead of 'short'
  1222. # medium: Jan 5, 2016, 10:20:31 PM | 5 janv. 2016 22:20:31
  1223. # short: 1/5/16, 10:20 PM | 5/01/16 22:20
  1224. # Formatting available here : http://babel.pocoo.org/en/latest/dates.html#date-fields
  1225. return babel.dates.format_datetime(localized_datetime, dt_format, locale=locale)
  1226. def format_time(
  1227. env: Environment,
  1228. value: datetime.time | datetime.datetime | str,
  1229. tz: str | typing.Literal[False] = False,
  1230. time_format: str = 'medium',
  1231. lang_code: str | None = None,
  1232. ) -> str:
  1233. """ Format the given time (hour, minute and second) with the current user preference (language, format, ...)
  1234. :param env:
  1235. :param value: the time to format
  1236. :type value: `datetime.time` instance. Could be timezoned to display tzinfo according to format (e.i.: 'full' format)
  1237. :param tz: name of the timezone in which the given datetime should be localized
  1238. :param time_format: one of “full”, “long”, “medium”, or “short”, or a custom time pattern
  1239. :param lang_code: ISO
  1240. :rtype str
  1241. """
  1242. if not value:
  1243. return ''
  1244. if isinstance(value, datetime.time):
  1245. localized_time = value
  1246. else:
  1247. if isinstance(value, str):
  1248. value = odoo.fields.Datetime.from_string(value)
  1249. assert isinstance(value, datetime.datetime)
  1250. tz_name = tz or env.user.tz or 'UTC'
  1251. utc_datetime = pytz.utc.localize(value, is_dst=False)
  1252. try:
  1253. context_tz = pytz.timezone(tz_name)
  1254. localized_time = utc_datetime.astimezone(context_tz).timetz()
  1255. except Exception:
  1256. localized_time = utc_datetime.timetz()
  1257. lang = get_lang(env, lang_code)
  1258. locale = babel_locale_parse(lang.code)
  1259. if not time_format:
  1260. time_format = posix_to_ldml(lang.time_format, locale=locale)
  1261. return babel.dates.format_time(localized_time, format=time_format, locale=locale)
  1262. def _format_time_ago(
  1263. env: Environment,
  1264. time_delta: datetime.timedelta,
  1265. lang_code: str | None = None,
  1266. add_direction: bool = True,
  1267. ) -> str:
  1268. if not lang_code:
  1269. langs: list[str] = [code for code, _ in env['res.lang'].get_installed()]
  1270. if (ctx_lang := env.context.get('lang')) in langs:
  1271. lang_code = ctx_lang
  1272. else:
  1273. lang_code = env.user.company_id.partner_id.lang or langs[0]
  1274. assert isinstance(lang_code, str)
  1275. locale = babel_locale_parse(lang_code)
  1276. return babel.dates.format_timedelta(-time_delta, add_direction=add_direction, locale=locale)
  1277. def format_decimalized_number(number: float, decimal: int = 1) -> str:
  1278. """Format a number to display to nearest metrics unit next to it.
  1279. Do not display digits if all visible digits are null.
  1280. Do not display units higher then "Tera" because most people don't know what
  1281. a "Yotta" is.
  1282. ::
  1283. >>> format_decimalized_number(123_456.789)
  1284. 123.5k
  1285. >>> format_decimalized_number(123_000.789)
  1286. 123k
  1287. >>> format_decimalized_number(-123_456.789)
  1288. -123.5k
  1289. >>> format_decimalized_number(0.789)
  1290. 0.8
  1291. """
  1292. for unit in ['', 'k', 'M', 'G']:
  1293. if abs(number) < 1000.0:
  1294. return "%g%s" % (round(number, decimal), unit)
  1295. number /= 1000.0
  1296. return "%g%s" % (round(number, decimal), 'T')
  1297. def format_decimalized_amount(amount: float, currency=None) -> str:
  1298. """Format an amount to display the currency and also display the metric unit
  1299. of the amount.
  1300. ::
  1301. >>> format_decimalized_amount(123_456.789, env.ref("base.USD"))
  1302. $123.5k
  1303. """
  1304. formated_amount = format_decimalized_number(amount)
  1305. if not currency:
  1306. return formated_amount
  1307. if currency.position == 'before':
  1308. return "%s%s" % (currency.symbol or '', formated_amount)
  1309. return "%s %s" % (formated_amount, currency.symbol or '')
  1310. def format_amount(env: Environment, amount: float, currency, lang_code: str | None = None) -> str:
  1311. fmt = "%.{0}f".format(currency.decimal_places)
  1312. lang = env['res.lang'].browse(get_lang(env, lang_code).id)
  1313. formatted_amount = lang.format(fmt, currency.round(amount), grouping=True)\
  1314. .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
  1315. pre = post = u''
  1316. if currency.position == 'before':
  1317. pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=currency.symbol or '')
  1318. else:
  1319. post = u'\N{NO-BREAK SPACE}{symbol}'.format(symbol=currency.symbol or '')
  1320. return u'{pre}{0}{post}'.format(formatted_amount, pre=pre, post=post)
  1321. def format_duration(value: float) -> str:
  1322. """ Format a float: used to display integral or fractional values as
  1323. human-readable time spans (e.g. 1.5 as "01:30").
  1324. """
  1325. hours, minutes = divmod(abs(value) * 60, 60)
  1326. minutes = round(minutes)
  1327. if minutes == 60:
  1328. minutes = 0
  1329. hours += 1
  1330. if value < 0:
  1331. return '-%02d:%02d' % (hours, minutes)
  1332. return '%02d:%02d' % (hours, minutes)
  1333. consteq = hmac_lib.compare_digest
  1334. class ReadonlyDict(Mapping[K, T], typing.Generic[K, T]):
  1335. """Helper for an unmodifiable dictionary, not even updatable using `dict.update`.
  1336. This is similar to a `frozendict`, with one drawback and one advantage:
  1337. - `dict.update` works for a `frozendict` but not for a `ReadonlyDict`.
  1338. - `json.dumps` works for a `frozendict` by default but not for a `ReadonlyDict`.
  1339. This comes from the fact `frozendict` inherits from `dict`
  1340. while `ReadonlyDict` inherits from `collections.abc.Mapping`.
  1341. So, depending on your needs,
  1342. whether you absolutely must prevent the dictionary from being updated (e.g., for security reasons)
  1343. or you require it to be supported by `json.dumps`, you can choose either option.
  1344. E.g.
  1345. data = ReadonlyDict({'foo': 'bar'})
  1346. data['baz'] = 'xyz' # raises exception
  1347. data.update({'baz', 'xyz'}) # raises exception
  1348. dict.update(data, {'baz': 'xyz'}) # raises exception
  1349. """
  1350. def __init__(self, data):
  1351. self.__data = dict(data)
  1352. def __contains__(self, key: K):
  1353. return key in self.__data
  1354. def __getitem__(self, key: K) -> T:
  1355. try:
  1356. return self.__data[key]
  1357. except KeyError:
  1358. if hasattr(type(self), "__missing__"):
  1359. return self.__missing__(key)
  1360. raise
  1361. def __len__(self):
  1362. return len(self.__data)
  1363. def __iter__(self):
  1364. return iter(self.__data)
  1365. class DotDict(dict):
  1366. """Helper for dot.notation access to dictionary attributes
  1367. E.g.
  1368. foo = DotDict({'bar': False})
  1369. return foo.bar
  1370. """
  1371. def __getattr__(self, attrib):
  1372. val = self.get(attrib)
  1373. return DotDict(val) if isinstance(val, dict) else val
  1374. def get_diff(data_from, data_to, custom_style=False, dark_color_scheme=False):
  1375. """
  1376. Return, in an HTML table, the diff between two texts.
  1377. :param tuple data_from: tuple(text, name), name will be used as table header
  1378. :param tuple data_to: tuple(text, name), name will be used as table header
  1379. :param tuple custom_style: string, style css including <style> tag.
  1380. :param bool dark_color_scheme: true if dark color scheme is used
  1381. :return: a string containing the diff in an HTML table format.
  1382. """
  1383. def handle_style(html_diff, custom_style, dark_color_scheme):
  1384. """ The HtmlDiff lib will add some useful classes on the DOM to
  1385. identify elements. Simply append to those classes some BS4 ones.
  1386. For the table to fit the modal width, some custom style is needed.
  1387. """
  1388. to_append = {
  1389. 'diff_header': 'bg-600 text-center align-top px-2',
  1390. 'diff_next': 'd-none',
  1391. }
  1392. for old, new in to_append.items():
  1393. html_diff = html_diff.replace(old, "%s %s" % (old, new))
  1394. html_diff = html_diff.replace('nowrap', '')
  1395. colors = ('#7f2d2f', '#406a2d', '#51232f', '#3f483b') if dark_color_scheme else (
  1396. '#ffc1c0', '#abf2bc', '#ffebe9', '#e6ffec')
  1397. html_diff += custom_style or '''
  1398. <style>
  1399. .modal-dialog.modal-lg:has(table.diff) {
  1400. max-width: 1600px;
  1401. padding-left: 1.75rem;
  1402. padding-right: 1.75rem;
  1403. }
  1404. table.diff { width: 100%%; }
  1405. table.diff th.diff_header { width: 50%%; }
  1406. table.diff td.diff_header { white-space: nowrap; }
  1407. table.diff td { word-break: break-all; vertical-align: top; }
  1408. table.diff .diff_chg, table.diff .diff_sub, table.diff .diff_add {
  1409. display: inline-block;
  1410. color: inherit;
  1411. }
  1412. table.diff .diff_sub, table.diff td:nth-child(3) > .diff_chg { background-color: %s }
  1413. table.diff .diff_add, table.diff td:nth-child(6) > .diff_chg { background-color: %s }
  1414. table.diff td:nth-child(3):has(>.diff_chg, .diff_sub) { background-color: %s }
  1415. table.diff td:nth-child(6):has(>.diff_chg, .diff_add) { background-color: %s }
  1416. </style>
  1417. ''' % colors
  1418. return html_diff
  1419. diff = HtmlDiff(tabsize=2).make_table(
  1420. data_from[0].splitlines(),
  1421. data_to[0].splitlines(),
  1422. data_from[1],
  1423. data_to[1],
  1424. context=True, # Show only diff lines, not all the code
  1425. numlines=3,
  1426. )
  1427. return handle_style(diff, custom_style, dark_color_scheme)
  1428. def hmac(env, scope, message, hash_function=hashlib.sha256):
  1429. """Compute HMAC with `database.secret` config parameter as key.
  1430. :param env: sudo environment to use for retrieving config parameter
  1431. :param message: message to authenticate
  1432. :param scope: scope of the authentication, to have different signature for the same
  1433. message in different usage
  1434. :param hash_function: hash function to use for HMAC (default: SHA-256)
  1435. """
  1436. if not scope:
  1437. raise ValueError('Non-empty scope required')
  1438. secret = env['ir.config_parameter'].get_param('database.secret')
  1439. message = repr((scope, message))
  1440. return hmac_lib.new(
  1441. secret.encode(),
  1442. message.encode(),
  1443. hash_function,
  1444. ).hexdigest()
  1445. def hash_sign(env, scope, message_values, expiration=None, expiration_hours=None):
  1446. """ Generate an urlsafe payload signed with the HMAC signature for an iterable set of data.
  1447. This feature is very similar to JWT, but in a more generic implementation that is inline with out previous hmac implementation.
  1448. :param env: sudo environment to use for retrieving config parameter
  1449. :param scope: scope of the authentication, to have different signature for the same
  1450. message in different usage
  1451. :param message_values: values to be encoded inside the payload
  1452. :param expiration: optional, a datetime or timedelta
  1453. :param expiration_hours: optional, a int representing a number of hours before expiration. Cannot be set at the same time as expiration
  1454. :return: the payload that can be used as a token
  1455. """
  1456. assert not (expiration and expiration_hours)
  1457. assert message_values is not None
  1458. if expiration_hours:
  1459. expiration = datetime.datetime.now() + datetime.timedelta(hours=expiration_hours)
  1460. else:
  1461. if isinstance(expiration, datetime.timedelta):
  1462. expiration = datetime.datetime.now() + expiration
  1463. expiration_timestamp = 0 if not expiration else int(expiration.timestamp())
  1464. message_strings = json.dumps(message_values)
  1465. hash_value = hmac(env, scope, f'1:{message_strings}:{expiration_timestamp}', hash_function=hashlib.sha256)
  1466. token = b"\x01" + expiration_timestamp.to_bytes(8, 'little') + bytes.fromhex(hash_value) + message_strings.encode()
  1467. return base64.urlsafe_b64encode(token).decode().rstrip('=')
  1468. def verify_hash_signed(env, scope, payload):
  1469. """ Verify and extract data from a given urlsafe payload generated with hash_sign()
  1470. :param env: sudo environment to use for retrieving config parameter
  1471. :param scope: scope of the authentication, to have different signature for the same
  1472. message in different usage
  1473. :param payload: the token to verify
  1474. :return: The payload_values if the check was successful, None otherwise.
  1475. """
  1476. token = base64.urlsafe_b64decode(payload.encode()+b'===')
  1477. version = token[:1]
  1478. if version != b'\x01':
  1479. raise ValueError('Unknown token version')
  1480. expiration_value, hash_value, message = token[1:9], token[9:41].hex(), token[41:].decode()
  1481. expiration_value = int.from_bytes(expiration_value, byteorder='little')
  1482. hash_value_expected = hmac(env, scope, f'1:{message}:{expiration_value}', hash_function=hashlib.sha256)
  1483. if consteq(hash_value, hash_value_expected) and (expiration_value == 0 or datetime.datetime.now().timestamp() < expiration_value):
  1484. message_values = json.loads(message)
  1485. return message_values
  1486. return None
  1487. def limited_field_access_token(record, field_name, timestamp=None):
  1488. """Generate a token granting access to the given record and field_name from
  1489. the binary routes (/web/content or /web/image).
  1490. The validitiy of the token is determined by the timestamp parameter.
  1491. When it is not specified, a timestamp is automatically generated with a
  1492. validity of at least 14 days. For a given record and field_name, the
  1493. generated timestamp is deterministic within a 14-day period (even across
  1494. different days/months/years) to allow browser caching, and expires after
  1495. maximum 42 days to prevent infinite access. Different record/field
  1496. combinations expire at different times to prevent thundering herd problems.
  1497. :param record: the record to generate the token for
  1498. :type record: class:`odoo.models.Model`
  1499. :param field_name: the field name of record to generate the token for
  1500. :type field_name: str
  1501. :param timestamp: expiration timestamp of the token, or None to generate one
  1502. :type timestamp: int, optional
  1503. :return: the token, which includes the timestamp in hex format
  1504. :rtype: string
  1505. """
  1506. record.ensure_one()
  1507. if not timestamp:
  1508. unique_str = repr((record._name, record.id, field_name))
  1509. two_weeks = 1209600 # 2 * 7 * 24 * 60 * 60
  1510. start_of_period = int(time.time()) // two_weeks * two_weeks
  1511. adler32_max = 4294967295
  1512. jitter = two_weeks * zlib.adler32(unique_str.encode()) // adler32_max
  1513. timestamp = hex(start_of_period + 2 * two_weeks + jitter)
  1514. token = hmac(record.env(su=True), "binary", (record._name, record.id, field_name, timestamp))
  1515. return f"{token}o{timestamp}"
  1516. def verify_limited_field_access_token(record, field_name, access_token):
  1517. """Verify the given access_token grants access to field_name of record.
  1518. In particular, the token must have the right format, must be valid for the
  1519. given record, and must not have expired.
  1520. :param record: the record to verify the token for
  1521. :type record: class:`odoo.models.Model`
  1522. :param field_name: the field name of record to verify the token for
  1523. :type field_name: str
  1524. :param access_token: the access token to verify
  1525. :type access_token: str
  1526. :return: whether the token is valid for the record/field_name combination at
  1527. the current date and time
  1528. :rtype: bool
  1529. """
  1530. *_, timestamp = access_token.rsplit("o", 1)
  1531. return consteq(
  1532. access_token, limited_field_access_token(record, field_name, timestamp)
  1533. ) and datetime.datetime.now() < datetime.datetime.fromtimestamp(int(timestamp, 16))
  1534. ADDRESS_REGEX = re.compile(r'^(.*?)(\s[0-9][0-9\S]*)?(?: - (.+))?$', flags=re.DOTALL)
  1535. def street_split(street):
  1536. match = ADDRESS_REGEX.match(street or '')
  1537. results = match.groups('') if match else ('', '', '')
  1538. return {
  1539. 'street_name': results[0].strip(),
  1540. 'street_number': results[1].strip(),
  1541. 'street_number2': results[2],
  1542. }
  1543. def is_list_of(values, type_: type) -> bool:
  1544. """Return True if the given values is a list / tuple of the given type.
  1545. :param values: The values to check
  1546. :param type_: The type of the elements in the list / tuple
  1547. """
  1548. return isinstance(values, (list, tuple)) and all(isinstance(item, type_) for item in values)
  1549. def has_list_types(values, types: tuple[type, ...]) -> bool:
  1550. """Return True if the given values have the same types as
  1551. the one given in argument, in the same order.
  1552. :param values: The values to check
  1553. :param types: The types of the elements in the list / tuple
  1554. """
  1555. return (
  1556. isinstance(values, (list, tuple)) and len(values) == len(types)
  1557. and all(itertools.starmap(isinstance, zip(values, types)))
  1558. )
  1559. def get_flag(country_code: str) -> str:
  1560. """Get the emoji representing the flag linked to the country code.
  1561. This emoji is composed of the two regional indicator emoji of the country code.
  1562. """
  1563. return "".join(chr(int(f"1f1{ord(c)+165:02x}", base=16)) for c in country_code)
  1564. def format_frame(frame) -> str:
  1565. code = frame.f_code
  1566. return f'{code.co_name} {code.co_filename}:{frame.f_lineno}'
  1567. def named_to_positional_printf(string: str, args: Mapping) -> tuple[str, tuple]:
  1568. """ Convert a named printf-style format string with its arguments to an
  1569. equivalent positional format string with its arguments. This implementation
  1570. does not support escaped ``%`` characters (``"%%"``).
  1571. """
  1572. if '%%' in string:
  1573. raise ValueError(f"Unsupported escaped '%' in format string {string!r}")
  1574. pargs = _PrintfArgs(args)
  1575. return string % pargs, tuple(pargs.values)
  1576. class _PrintfArgs:
  1577. """ Helper object to turn a named printf-style format string into a positional one. """
  1578. __slots__ = ('mapping', 'values')
  1579. def __init__(self, mapping):
  1580. self.mapping: Mapping = mapping
  1581. self.values: list = []
  1582. def __getitem__(self, key):
  1583. self.values.append(self.mapping[key])
  1584. return "%s"
上海开阖软件有限公司 沪ICP备12045867号-1