openupgradelib/openupgrade_tools.py
# -*- coding: utf-8 -*- # pylint: disable=C8202
##############################################################################
#
# OpenERP, Open Source Management Solution
# This module copyright (C) 2012-2014 Therp BV (<http://therp.nl>)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
##############################################################################
# A collection of functions split off from openupgrade.py
# with no or only minimal dependencies
import logging
from lxml.etree import tostring
from lxml.html import fromstring
logger = logging.getLogger(__name__)
def table_exists(cr, table):
"""Check whether a certain table or view exists"""
cr.execute("SELECT 1 FROM pg_class WHERE relname = %s", (table,))
return cr.fetchone()
def column_exists(cr, table, column):
"""Check whether a certain column exists"""
cr.execute(
"SELECT count(attname) FROM pg_attribute "
"WHERE attrelid = "
"( SELECT oid FROM pg_class WHERE relname = %s ) "
"AND attname = %s",
(table, column),
)
return cr.fetchone()[0] == 1
def convert_html_fragment(html_string, replacements, pretty_print=True):
"""Get a string that contains XML and apply replacements to it.
:param str xml_string:
XML string object.
:param iterable[*dict] replacements:
The spec is any iterable full of dicts with this format:
.. code-block:: python
{
# This key is required, to find matching nodes
"selector": ".carousel .item",
# This is the default. Use ``xpath`` to select with XPath
"selector_mode": "css",
# Other keys are kwargs for ``convert_xml_node()``.
"class_rm": "item",
"class_add": "carousel-item",
},
:param bool pretty_print:
Indicates if the returned XML string should be indented.
:return str:
Converted XML string.
"""
try:
# When the fragment has no common node, lxml wraps the code under a common one.
# For example: `<p><p/><p><p/>` is parsed as `<div><p><p/><p><p/></div>`
# So we force a custom wrapper tag on every parsed string so every xml receives
# the same treatment and we can extract it later with no harm
if "<?xml " in html_string:
# XML compliant string - no need to wrap it.
fragment = fromstring(html_string)
else:
fragment = fromstring(
"<fragment_wrapper>{}</fragment_wrapper>".format(html_string)
)
except Exception:
logging.error("Failure converting string to DOM:\n%s", html_string)
raise
# We don't want to update any fragment which has no changes after all the
# replacements are checked but the lxml parser and pretty print could make some
# reformatting on the original code.
parsed_html_string = tostring(
fragment, pretty_print=pretty_print, encoding="unicode"
)
for spec in replacements:
instructions = spec.copy()
# Find matching nodes
selector = instructions.pop("selector")
mode = instructions.pop("selector_mode", "css")
assert mode in {"css", "xpath"}
finder = fragment.cssselect if mode == "css" else fragment.xpath
nodes = finder(selector)
# Apply node conversions as instructed
for node in nodes:
convert_xml_node(node, **instructions)
# So if there were no replacement we just return the original string as it was
new_html_string = tostring(fragment, pretty_print=pretty_print, encoding="unicode")
if new_html_string == parsed_html_string:
return html_string
new_html_string = new_html_string.replace("<fragment_wrapper>", "").replace(
"</fragment_wrapper>", ""
)
return new_html_string
# flake8: noqa: C901
def convert_xml_node(
node,
attr_add=None,
attr_rm=frozenset(),
class_add="",
class_rm="",
style_add=None,
style_rm=frozenset(),
tag="",
wrap="",
attr_rp=None,
class_rp_by_inline=None,
):
"""Apply conversions to an XML node.
All parameters except :param:`node` can be a callable that return the
expected type as specified in each of them below.
The callable would be called with these **keyword-only** arguments:
* ``attrs``: A ``dict`` of the original attributes in the node.
* ``classes``: A ``set`` of the original classes in the node.
* ``styles``: A ``dict`` of the original styles in the node.
* ``tag``: A ``str`` indicating the original node tag.
Each one of them has the same type as the expected type
:param lxml.etree.Element node:
Node to be modified.
:param dict attr_add:
Attributes to add.
If the attribute is present, it won't be overwritten unless you add
it also to :param:`attr_rm`.
:param set attr_rm:
Attributes to remove.
:param str class_add:
Space-separated list of classes to add (for HTML nodes).
:param str class_rm:
Space-separated list of classes to remove (for HTML nodes).
:param dict style_add:
CSS styles to be added inline to the node (for HTML nodes). I.e.,
if you pass ``{"display": "none"}``,
a ``<div style="background-color:gray/>`` node would become
``<div style="background-color:gray;display:none/>``.
If the style is present, it won't be overwritten unless you add
it also to :param:`style_rm`.
:param set style_rm:
CSS styles to remove from the node (for HTML nodes). I.e.,
if you pass ``{"display"}``,
a ``<div style="background-color:gray;display:none/>`` node
would become ``<div style="background-color:gray/>``.
:param str tag:
Use it to alter the element tag.
:param str wrap:
XML element that will wrap the :param:`node`.
:param dict attr_rp:
Specify a dict of attribute to replace from old to the new one
Ex: {"data-toggle": "data-bs-togle"} (typical case when convert BS4 to BS5 in odoo 16)
:param dict class_rp_by_inline:
Specify a dict of class to replace with inline css
Ex: {"text-justify": ["text-align: justify", "anothor_inline"]}
(BS5 has removed text-justify class)
"""
# Fix params
attr_add = attr_add or {}
attr_rp = attr_rp or {}
class_rp_by_inline = class_rp_by_inline or {}
class_add = set(class_add.split())
class_rm = set(class_rm.split())
style_add = style_add or {}
# Obtain attributes, classes and styles
classes = set(node.attrib.get("class", "").split())
styles = node.attrib.get("style", "").split(";")
styles = {
key.strip(): val.strip()
for key, val in (style.split(":", 1) for style in styles if ":" in style)
}
# Convert incoming callable arguments into values
originals = {
"attrs": dict(node.attrib.items()),
"classes": classes.copy(),
"styles": styles.copy(),
"tag": node.tag,
}
_call = lambda v: v(**originals) if callable(v) else v # noqa: E731
attr_add = _call(attr_add)
attr_rm = _call(attr_rm)
attr_rp = _call(attr_rp)
class_rp_by_inline = _call(class_rp_by_inline)
class_add = _call(class_add)
class_rm = _call(class_rm)
style_add = _call(style_add)
style_rm = _call(style_rm)
tag = _call(tag)
wrap = _call(wrap)
# Patch node attributes
if attr_add or attr_rm or attr_rp or class_rp_by_inline:
if class_rp_by_inline:
inline_style = ""
for _, value in class_rp_by_inline.items():
for inline_css_style in value:
inline_style += inline_css_style + ";"
if "style" in node.attrib and node.attrib["style"]:
node.attrib["style"] += inline_style
else:
node.attrib["style"] = inline_style
else:
for key, value in attr_rp.items():
if key in node.attrib:
node.attrib[value] = node.attrib.pop(key, None)
for key in attr_rm:
node.attrib.pop(key, None)
for key, value in attr_add.items():
if key not in node.attrib:
node.attrib[key] = value
# Patch node classes
if class_add or class_rm:
classes = (classes | class_add) ^ class_rm
classes = " ".join(classes)
if classes:
node.attrib["class"] = classes
else:
node.attrib.pop("class", None)
# Patch node styles
if style_add or style_rm:
for key in style_rm:
styles.pop(key, None)
for key, value in style_add.items():
styles.setdefault(key, value)
styles = ";".join(map(":".join, styles.items()))
if styles:
node.attrib["style"] = styles
else:
node.attrib.pop("style", None)
# Change its tag if needed
if tag:
node.tag = tag
# Wrap it if needed; see https://stackoverflow.com/a/56037842/1468388
if wrap:
wrapper = fromstring(wrap)
node.getparent().replace(node, wrapper)
wrapper.append(node)
def convert_html_replacement_class_shortcut(class_rm="", class_add="", **kwargs):
"""Shortcut to create a class replacement spec.
:param str class_rm:
Space-separated string with classes to remove. If a selector kwarg
is not provided, these will be transformed to a selector, effectively
generating a class replacement result. For example, if this parameter
is ``"label badge"``, the default selector will be ``".label.badge"``.
:param str class_add:
Space-separated string with classes to add.
:return dict:
Generated spec, to be included in a list of replacements to be
passed to :meth:`convert_xml_fragment`.
"""
kwargs.setdefault("selector", ".%s" % ".".join(class_rm.split()))
assert kwargs["selector"] != "."
kwargs.update(
{
"class_rm": class_rm,
"class_add": class_add,
}
)
return kwargs
def replace_html_replacement_class_rp_by_inline_shortcut(
class_rp_by_inline="", **kwargs
):
"""Shortcut to replace an attribute spec.
:param dict attr_rp:
EX: {'data-toggle': 'data-bs-toggle'}
Where the 'key' is the attribute will be replaced by the 'value'
:return dict:
Generated spec, to be included in a list of replacements to be
passed to :meth:`convert_xml_fragment`.
"""
# Disallow selector to be empty
assert "selector" in kwargs and kwargs["selector"] != ""
# Also to be able to get exact element that have that attribute need selector_mode xpath
assert "selector_mode" in kwargs and kwargs["selector_mode"] == "xpath"
kwargs.update(
{
"class_rp_by_inline": class_rp_by_inline,
}
)
return kwargs
def replace_html_replacement_attr_shortcut(attr_rp="", **kwargs):
"""Shortcut to replace an attribute spec.
:param dict attr_rp:
EX: {'data-toggle': 'data-bs-toggle'}
Where the 'key' is the attribute will be replaced by the 'value'
:return dict:
Generated spec, to be included in a list of replacements to be
passed to :meth:`convert_xml_fragment`.
"""
# Disallow selector to be empty
assert "selector" in kwargs and kwargs["selector"] != ""
# Also to be able to get exact element that have that attribute need selector_mode xpath
assert "selector_mode" in kwargs and kwargs["selector_mode"] == "xpath"
kwargs.update(
{
"attr_rp": attr_rp,
}
)
return kwargs
def invalidate_cache(env, flush=True):
"""Version-independent cache invalidation.
:param flush: whether pending updates should be flushed before invalidation.
It is ``True`` by default, which ensures cache consistency.
Do not use this parameter unless you know what you are doing.
"""
# It needs to be loaded after odoo is imported
from .openupgrade import version_info
version = version_info[0]
# Warning on possibly untested versions where chunked might not work as expected
if version > 17: # unreleased version at this time
logger.warning(
"openupgradelib.invalidate_cache hasn't been tested on Odoo {}. "
"Please report any issue you may find and consider bumping this warning "
"to the next version otherwise.".format(version)
)
# 16.0: invalidate_all is re-introduced (with flush_all)
if version >= 16:
env.invalidate_all(flush=flush)
# 13.0: the write cache and flush is introduced
elif version >= 13:
if flush:
env["base"].flush()
env.cache.invalidate()
# 11.0: the invalidate_all method is deprecated
elif version >= 11:
env.cache.invalidate()
# 8.0: new api
elif version >= 8:
env.invalidate_all()
else:
raise Exception("Not supported Odoo version for this method.")