diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py
index 0a0a97ad86b..126fa64d5fb 100644
--- a/addons/http_routing/models/ir_http.py
+++ b/addons/http_routing/models/ir_http.py
@@ -569,7 +569,7 @@ class IrHttp(models.AbstractModel):
elif isinstance(exception, QWebException):
values.update(qweb_exception=exception)
- if type(exception.error) == exceptions.AccessError:
+ if isinstance(exception.__cause__, exceptions.AccessError):
code = 403
elif isinstance(exception, werkzeug.exceptions.HTTPException):
diff --git a/addons/http_routing/views/http_routing_template.xml b/addons/http_routing/views/http_routing_template.xml
index d96f241ecd4..6cc43f8c3fb 100644
--- a/addons/http_routing/views/http_routing_template.xml
+++ b/addons/http_routing/views/http_routing_template.xml
@@ -38,9 +38,9 @@
-
+
Error message:
-
+
The error occurred while rendering the template
diff --git a/addons/mail/models/ir_qweb.py b/addons/mail/models/ir_qweb.py
index ea058b131b9..d5e4791c343 100644
--- a/addons/mail/models/ir_qweb.py
+++ b/addons/mail/models/ir_qweb.py
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
from odoo import models
-class QWebCodeFound(Exception):
- """
- Exception raised when a qweb compilation encounter dynamic content if the
- option `raise_on_code` is True.
- """
-
class IrQweb(models.AbstractModel):
+ """ Add ``raise_on_code`` option for qweb. When this option is activated
+ then all directives are prohibited.
+ """
_inherit = 'ir.qweb'
def _get_template_cache_keys(self):
@@ -15,5 +12,5 @@ class IrQweb(models.AbstractModel):
def _compile_directives(self, el, options, indent):
if options.get('raise_on_code'):
- raise QWebCodeFound()
+ raise PermissionError("This rendering mode prohibits the use of directives.")
return super()._compile_directives(el, options, indent)
diff --git a/addons/mail/models/mail_render_mixin.py b/addons/mail/models/mail_render_mixin.py
index 45f55cd8c0c..e716ed18067 100644
--- a/addons/mail/models/mail_render_mixin.py
+++ b/addons/mail/models/mail_render_mixin.py
@@ -10,7 +10,6 @@ from lxml import html
from markupsafe import Markup
from werkzeug import urls
-from .ir_qweb import QWebCodeFound
from odoo import _, api, fields, models, tools
from odoo.addons.base.models.ir_qweb import QWebException
from odoo.exceptions import UserError, AccessError
@@ -286,11 +285,11 @@ class MailRenderMixin(models.AbstractModel):
# remove the rendered tag
that was added in order to wrap potentially multiples nodes into one.
render_result = render_result[5:-6]
except Exception as e:
- if isinstance(e, QWebException) and isinstance(e.error, QWebCodeFound):
+ if isinstance(e, QWebException) and isinstance(e.__cause__, PermissionError):
group = self.env.ref('mail.group_mail_template_editor')
- raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name))
+ raise AccessError(_('Only users belonging to the "%s" group can modify dynamic templates.', group.name)) from e
_logger.info("Failed to render template : %s", template_src, exc_info=True)
- raise UserError(_("Failed to render QWeb template : %s)", e))
+ raise UserError(_("Failed to render QWeb template : %s)", e)) from e
results[record.id] = render_result
return results
diff --git a/addons/project/views/project_update_templates.xml b/addons/project/views/project_update_templates.xml
index 36056ce4b00..c8c297aa67b 100644
--- a/addons/project/views/project_update_templates.xml
+++ b/addons/project/views/project_update_templates.xml
@@ -24,7 +24,7 @@
Milestones
-
+
-
@@ -38,7 +38,7 @@
the deadline of the following milestones has been updated:
the deadline of the following milestone has been updated:
-
+
-
( => )
@@ -50,7 +50,7 @@
The following milestones have been added:
The following milestone has been added:
-
+
-
diff --git a/addons/stock/tests/test_report.py b/addons/stock/tests/test_report.py
index 04f71477487..1ed7c451409 100644
--- a/addons/stock/tests/test_report.py
+++ b/addons/stock/tests/test_report.py
@@ -54,7 +54,7 @@ class TestReports(TestReportsCommon):
'company_id': self.env.company.id,
})
report = self.env.ref('stock.label_lot_template')
- target = b'\n\n^XA\n^FO100,50\n^A0N,44,33^FD[C418]Mellohi^FS\n^FO100,100\n^A0N,44,33^FDLN/SN:Volume-Beta^FS\n^FO100,150^BY3\n^BCN,100,Y,N,N\n^FDVolume-Beta^FS\n^XZ\n\n'
+ target = b'\n\n^XA\n^FO100,50\n^A0N,44,33^FD[C418]Mellohi^FS\n^FO100,100\n^A0N,44,33^FDLN/SN:Volume-Beta^FS\n^FO100,150^BY3\n^BCN,100,Y,N,N\n^FDVolume-Beta^FS\n^XZ\n'
rendering, qweb_type = report._render_qweb_text(lot1.id)
self.assertEqual(target, rendering.replace(b' ', b''), 'The rendering is not good')
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index 3cca00b392b..2d4af05a743 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -1086,16 +1086,17 @@ class Database(http.Controller):
templates = {}
with file_open("web/static/src/public/database_manager.qweb.html", "r") as fd:
- template = fd.read()
+ templates['database_manager'] = fd.read()
with file_open("web/static/src/public/database_manager.master_input.qweb.html", "r") as fd:
templates['master_input'] = fd.read()
with file_open("web/static/src/public/database_manager.create_form.qweb.html", "r") as fd:
templates['create_form'] = fd.read()
def load(template_name, options):
- return (html.fragment_fromstring(templates[template_name]), template_name)
+ fromstring = html.document_fromstring if template_name == 'database_manager' else html.fragment_fromstring
+ return (fromstring(templates[template_name]), template_name)
- return qweb_render(html.document_fromstring(template), d, load=load)
+ return qweb_render('database_manager', d, load)
@http.route('/web/database/selector', type='http', auth="none")
def selector(self, **kw):
diff --git a/addons/web_editor/models/ir_qweb.py b/addons/web_editor/models/ir_qweb.py
index 0d8e0e2e2d6..c85ea88c768 100644
--- a/addons/web_editor/models/ir_qweb.py
+++ b/addons/web_editor/models/ir_qweb.py
@@ -44,8 +44,6 @@ class IrQWeb(models.AbstractModel):
"""
_inherit = 'ir.qweb'
- # compile directives
-
def _compile_node(self, el, options, indent):
snippet_key = options.get('snippet-key')
if snippet_key == options['template'] \
@@ -71,10 +69,12 @@ class IrQWeb(models.AbstractModel):
return super()._compile_node(el, options, indent)
+ # compile directives
+
def _compile_directive_snippet(self, el, options, indent):
key = el.attrib.pop('t-snippet')
el.set('t-call', key)
- el.set('t-options', "{'snippet-key': '" + key + "'}")
+ el.set('t-options', f"{{'snippet-key': {key!r}}}")
View = self.env['ir.ui.view'].sudo()
view_id = View.get_view_id(key)
name = View.browse(view_id).name
@@ -85,41 +85,42 @@ class IrQWeb(models.AbstractModel):
escape(pycompat.to_text(view_id)),
escape(pycompat.to_text(el.findtext('keywords')))
)
- self._appendText(div, options)
+ self._append_text(div, options)
code = self._compile_node(el, options, indent)
- self._appendText('
', options)
+ self._append_text('
', options)
return code
def _compile_directive_snippet_call(self, el, options, indent):
key = el.attrib.pop('t-snippet-call')
el.set('t-call', key)
- el.set('t-options', "{'snippet-key': '" + key + "'}")
+ el.set('t-options', f"{{'snippet-key': {key!r}}}")
return self._compile_node(el, options, indent)
def _compile_directive_install(self, el, options, indent):
+ key = el.attrib.pop('t-install')
+ thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail')
if self.user_has_groups('base.group_system'):
- module = self.env['ir.module.module'].search([('name', '=', el.attrib.get('t-install'))])
+ module = self.env['ir.module.module'].search([('name', '=', key)])
if not module or module.state == 'installed':
return []
name = el.attrib.get('string') or 'Snippet'
- thumbnail = el.attrib.pop('t-thumbnail', 'oe-thumbnail')
div = '
' % (
escape(pycompat.to_text(name)),
module.id,
escape(pycompat.to_text(thumbnail))
)
- self._appendText(div, options)
+ self._append_text(div, options)
return []
- def _compile_directive_tag(self, el, options, indent):
- if el.get('t-placeholder'):
- el.set('t-att-placeholder', el.attrib.pop('t-placeholder'))
- return super(IrQWeb, self)._compile_directive_tag(el, options, indent)
+ def _compile_directive_placeholder(self, el, options, indent):
+ el.set('t-att-placeholder', el.attrib.pop('t-placeholder'))
+ return []
# order and ignore
def _directives_eval_order(self):
- directives = super(IrQWeb, self)._directives_eval_order()
+ directives = super()._directives_eval_order()
+ directives.insert(directives.index('att'), 'placeholder')
directives.insert(directives.index('call'), 'snippet')
directives.insert(directives.index('call'), 'snippet-call')
directives.insert(directives.index('call'), 'install')
diff --git a/odoo/addons/base/models/ir_qweb.py b/odoo/addons/base/models/ir_qweb.py
index 7777febea44..52823c03bec 100644
--- a/odoo/addons/base/models/ir_qweb.py
+++ b/odoo/addons/base/models/ir_qweb.py
@@ -1,18 +1,384 @@
# -*- coding: utf-8 -*-
+
+"""
+================
+IrQWeb / ir.qweb
+================
+
+Preamble
+========
+
+Technical documentation of the python operation of the rendering QWeb engine.
+
+Templating
+==========
+
+QWeb is the primary templating engine used by Odoo. It is an XML templating
+engine and used mostly to generate XML, HTML fragments and pages.
+
+Template directives are specified as XML attributes prefixed with ``t-``,
+for instance ``t-if`` for :ref:`reference/qweb/conditionals`, with elements
+and other attributes being rendered directly.
+
+To avoid element rendering, a placeholder element ``
`` is also available,
+which executes its directive but doesn't generate any output in and of
+itself.
+
+To create new XML template, please see :doc:`QWeb Templates documentation
+`
+
+Rendering process
+=================
+
+In **input** you have an XML template giving the corresponding input etree.
+Each etree input nodes are used to generate a python function. This fonction is
+called and will give the XML **output**.
+The ``_compile`` method is responsible to generate the function from the
+etree, that function is a python generator that yield one output line at a
+time. This generator is consumed by ``_render``. The generated function is orm
+cached.
+
+For performance, the **compile time** (when input, XML template or template
+id, is compiled into a function) is less important than the **rendering time**
+(when the function is called with the different values). The generation of the
+function is only done once (for a set of options, language, branding ...)
+because it is cached orm
+
+The output is in ``MarkupSafe`` format. ``MarkupSafe`` escapes characters so
+text is safe to use in HTML and XML. Characters that have special meanings
+are replaced so that they display as the actual characters. This mitigates
+injection attacks, meaning untrusted user input can safely be displayed on a
+page.
+
+At **compile time**, each dynamic attribute ``t-*`` will be compiled into
+specific python code. (For example ```` will insert the
+template "10" inside the output)
+
+At **compile time**, each directive removes the dynamic attribute it uses from
+the input node attributes. At the end of the compilation each input node, no
+dynamic attributes must remain.
+
+How the code works
+==================
+
+In the graphic below you can see theresume of the call of the methods performed
+in the IrQweb class.
+
+.. code-block:: rst
+
+ Odoo
+ ┗━► _render (returns MarkupSafe)
+ ┗━► _compile (returns function) ◄━━━━━━━━━━┓
+ ┗━► _compile_node (returns code string array) ◄━━━━━━━━┓ ┃
+ ┃ (skip the current node if found t-qweb-skip) ┃ ┃
+ ┃ (add technical directives: t-tag-open, t-tag-close, t-inner-content) ┃ ┃
+ ┃ ┃ ┃
+ ┣━► _directives_eval_order (defined directive order) ┃ ┃
+ ┣━► _compile_directives (loop) Consume all remaining directives ◄━━━┓ ┃ ┃
+ ┃ ┃ (e.g.: to change the indentation) ┃ ┃ ┃
+ ┃ ┣━► _compile_directive ┃ ┃ ┃
+ ┃ ┃ ┗━► t-if ━━► _compile_directive_if ━┫ ┃ ┃
+ ┃ ┃ ┗━► t-foreach ━━► _compile_directive_foreach ━┛ ┃ ┃
+ ┃ ┃ ┗━► t-inner-content ━━► _compile_directive_inner_content ◄━━━━━┓ ━┛ ┃
+ ┃ ┃ ┗━► t-options ━━► _compile_directive_options ┃ ┃
+ ┃ ┃ ┗━► t-set ━━► _compile_directive_set ◄━━┓ ┃ ┃
+ ┃ ┃ ┗━► t-call ━━► _compile_directive_call ━┛ ━┫ ━━━┛
+ ┃ ┃ ┗━► t-att ━━► _compile_directive_att ┃
+ ┃ ┃ ┗━► t-tag-open ━━► _compile_directive_open ◄━━┓ ┃
+ ┃ ┃ ┗━► t-tag-close ━━► _compile_directive_close ◄━━┫ ┃
+ ┃ ┃ ┗━► t-out ━━► _compile_directive_out ━┛ ━┫ ◄━━┓
+ ┃ ┃ ┗━► t-field ━━► _compile_directive_field ┃ ━┫
+ ┃ ┃ ┗━► t-esc ━━► _compile_directive_esc ┃ ━┛
+ ┃ ┃ ┗━► t-* ━━► ... ┃
+ ┃ ┃ ┃
+ ┗━━┻━► _compile_static_node ━┛
+
+
+The QWeb ``_render`` uses the function generated by the ``_compile`` method.
+Each XML node will go through the ``_compile_node`` method. If the
+node does not have dynamic directives or attributes (``_is_static_node``).
+A ``static`` is a node without ``t-*`` attributes, does not require dynamic
+rendering for its attributes.
+If it's a ``static`` node, the ``_compile_static_node`` method is called,
+otherwise it is the ``_compile_directives`` method after having prepared the
+order for calling the directives using the ``_directives_eval_order`` method.
+In the defined order, for each directive the method ``_compile_directive`` is
+called which itself dispatches to the methods corresponding to the directives
+``_compile_directive_[name of the directive]`` (for example: ``t-if`` =>
+``_compile_directive_if``). After all ordered directives, the directives
+attributes still present on the element are compiled.
+
+The ``_post_processing_att`` method is used for the generation of rendering
+attributes. If the attributes come from static XML template nodes then the
+method is called only once when generating the render function. Otherwise the
+method is called during each rendering.
+
+Each expression is compiled by the method ``_compile_expr`` into a python
+expression whose values are namespaced.
+
+Directives
+----------
+
+``t-debug``
+~~~~~~~~~~~
+**Values**: ``pdb``, ``ipdb``, ``pudb``, ``wdb``
+
+Activate the choosed debugger.
+
+When dev mode is enabled this allows python developers to have access to the
+state of variables being rendered. The code generated by the QWeb engine is
+not accessible, only the variables (values, compile_options, self) can be
+analyzed or the methods that called the QWeb rendering.
+
+``t-if``
+~~~~~~~~
+**Values**: python expression
+
+
+Add an python ``if`` condition to the code string array, and call
+``_compile_directives`` to level and add the code string array corresponding
+to the other directives and content.
+
+The structure of the dom is checked to possibly find a ``t-else`` or
+``t-elif``. If these directives exist then the compilation is performed and
+the nodes are marked not to be rendered twice.
+
+At **rendering time** the other directives code and content will used only if
+the expression is evaluated as truely.
+
+The ``t-else``, ``t-elif`` and ``t-if`` are not compiled at the same time like
+defined in ``_directives_eval_order`` method.
+```
+
+
+
+
+
+
+
+Result:
+
+0
+1
+2
+
+1
+```
+
+``t-else``
+~~~~~~~~~~
+**Values**: nothing
+
+Only validate the **input**, the compilation if inside the ``t-if`` directive.
+
+``t-elif``
+~~~~~~~~~~
+**Values**: python expression
+
+Only validate the **input**, the compilation if inside the ``t-if`` directive.
+
+``t-groups`` (``groups`` is an alias)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+**Values**: name of the allowed odoo user group, or preceded by ``!`` for
+prohibited groups
+
+The generated code uses ``user_has_groups`` Odoo method.
+
+``t-foreach``
+~~~~~~~~~~~~~
+**Values**: an expression returning the collection to iterate on
+
+This directive is used with ``t-as`` directive to defined the key name. The
+directive will be converted into a ``for`` loop. In this loop, different values
+are added to the dict (``values`` in the generated method) in addition to the
+key defined by ``t-name``, these are (``*_value``, ``*_index``, ``*_size``,
+``*_first``, ``*_last``).
+
+``t-as``
+~~~~~~~~
+**Values**: key name
+
+The compilation method only validates if ``t-as`` and ``t-foreach`` are on the
+same node.
+
+``t-options`` and ``t-options-*``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+**Values**: python expression
+
+It's use on the same node of another directive, it's used to configure the
+other directive. Used on the same ``input node`` of the directives ``t-call``,
+``t-field`` or ``t-out``.
+
+Create a ``values['__qweb_options__']`` dict from the optional ``t-options``
+expression and add each key-value ``t-options-key="expression value"`` to this
+dict. (for example: ``t-options="{'widget': 'float'}"`` is equal to
+``t-options-widget="'float'"``)
+
+``t-att``, ``t-att-*`` and ``t-attf-*``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+**Values**: python expression (or format string expression for ``t-attf-``)
+
+Compile the attributes to create ``values['__qweb_attrs__']`` dictionary code
+in the compiled function. Use the ``t-att`` expression and add each key-value
+``t-att-key="expression value"`` to this dict. (for example:
+``t-att="{'class': f'float_{1}'}"`` is equal to ``t-att-class="f'float_{1}'"``
+and is equal to ``t-attf-class="float_{{1}}")
+
+The attributes come from new namespaces, static elements (not preceded
+by ``t-``) and dynamic attributes ``t-att``, attributes prefixed by ``t-att-``
+(python expression) or ``t-attf`` (format string expression).
+
+``t-call``
+~~~~~~~~~~
+**Values**: format string expression for template name
+
+Serves the called template in place of the current ``t-call`` node.
+
+Here are the different steps performed by the generated python code:
+
+#. copy the ``values`` dictionary;
+#. render the content (``_compile_directive_inner_content``) of the tag in a
+ separate method called with the previous copied values. This values can be
+ updated via t-set. The visible content of the rendering of the sub-content
+ is added as a magical value ``0`` (can be rendered with ``t-out="0"``);
+#. copy the ``options`` dictionary;
+#. compile the directive ``t-options`` and update the ``options`` dictionary
+ are, in added to the calling template and the ``nsmap`` values;
+#. get the compiled function from the ``_compile`` method;
+#. use the compiled function to serves the called template.
+
+``t-lang``
+~~~~~~~~~~
+**Values**: python expression
+
+Used to serve the called template (``t-call``) in another language. Used
+together with ``t-call``.
+
+This directive will be evaluate like ``t-options-lang``. Allows you to change
+the language in which the called template is rendered. It's in the ``t-call``
+directive that the language of the context of the ``ir.qweb`` recordset on
+which the ``_compile`` function is called is updated.
+
+``t-call-assets``
+~~~~~~~~~~~~~~~~~
+**Values**: format string for template name
+
+The generated code call the ``_get_asset_nodes`` method to get the list of
+(tagName, attrs and content). From each tuple a tag is created into the
+rendering.
+
+``t-out``
+~~~~~~~~~
+**Values**: python expression
+
+Output the given value or if falsy, display the content as default value.
+(for example: ``Default content``)
+
+The generated code add the value into the ``MarkupSafe`` rendering.
+If a widget is defined (``t-options-widget``), the generated code call the
+``_get_widget`` method to have the formatted field value and attributes. It's
+the ``ir.qweb.field.*`` models that format the value.
+
+``t-field``
+~~~~~~~~~~~
+**Values**: String representing the path to the field. (for example:
+``t-field="record.name"``)
+
+Output the field value or if falsy, display the content as default value.
+(for example: ``Default content``)
+
+Use ``t-out`` compile method but the generated code call ``_get_field``
+instead of ``_get_widget``. It's the ``ir.qweb.field.*`` models that format
+the value. The rendering model is chosen according to the type of field. The
+rendering model can be modified via the ``t-options-widget``.
+
+``t-esc``
+~~~~~~~~~
+Deprecated, please use ``t-out``
+
+``t-raw``
+~~~~~~~~~
+Deprecated, please use ``t-out``
+
+``t-set``
+~~~~~~~~~
+**Values**: key name
+
+The generated code update the key ``values`` dictionary equal to the value
+defined by ``t-value`` expression, ``t-valuef`` format string expression or
+to the ``MarkupSafe`` rendering come from the content of the node.
+
+``t-value``
+~~~~~~~~~~~
+**Values**: python expression
+
+The compilation method only validates if ``t-value`` and ``t-set`` are on the
+same node.
+
+``t-valuef``
+~~~~~~~~~~~~
+**Values**: format string expression
+
+The compilation method only validates if ``t-valuef`` and ``t-set`` are on the
+same node.
+
+``t-set-*`` and ``t-setf-*``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+**Values**: python expression or format string expression
+
+For each key-value ``t-set-key="value"`` the value will be compiled into a
+python expression or into a format string expression for ``t-setf-*``.
+At **rendering time** the ``values`` dictionary is updated.
+
+(for example: ``t-set-bib="1 + 1"`` is equal to ``t-set="bib" t-value="2"``)
+
+Technical directives
+--------------------
+
+Directive added automatically by IrQweb in order to go through the compilation
+methods.
+
+``t-tag-open``
+~~~~~~~~~~~~~~
+Used to generate the opening HTML/XML tags.
+
+``t-tag-close``
+~~~~~~~~~~~~~~
+Used to generate the closing HTML/XML tags.
+
+``t-inner-content``
+~~~~~~~~~~~~~~~~~~~
+Used to add the content of the node (text, tail and children nodes).
+If namespaces are declared on the current element then a copy of the options
+is made.
+
+``t-consumed-options``
+~~~~~~~~~~~~~~~~~~~~~~
+Raise an exception if the ``t-options`` is not consumed.
+
+``t-qweb-skip``
+~~~~~~~~~~~~~~~~~~~~~~
+Ignore rendering and directives for the curent **input** node.
+
+``t-else-valid``
+~~~~~~~~~~~~~~~~~~~~~~
+Mark a node with ``t-else`` or ``t-elif`` having a valid **input** dom
+structure.
+
+"""
+
import logging
-import os.path
import re
import traceback
import token
import tokenize
import io
-import copy
+import textwrap
from markupsafe import Markup, escape
from collections.abc import Sized, Mapping
from itertools import count, chain
-from textwrap import dedent, indent as _indent
-from lxml import etree, html
+from lxml import etree
from psycopg2.extensions import TransactionRollbackError
from odoo import api, models, tools
@@ -54,12 +420,25 @@ _SAFE_QWEB_OPCODES = _EXPR_OPCODES.union(to_opcodes([
unsafe_eval = eval
+VOID_ELEMENTS = frozenset([
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
+ 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'])
+# Terms allowed in addition to AVAILABLE_OBJECTS when compiling python expressions
+ALLOWED_KEYWORD = frozenset(['False', 'None', 'True', 'and', 'as', 'elif', 'else', 'for', 'if', 'in', 'is', 'not', 'or'] + list(_BUILTINS))
# regexpr for string formatting and extract ( ruby-style )|( jinja-style ) used in `_compile_format`
-_FORMAT_REGEX = re.compile(r'(?:#\{(.+?)\})|(?:\{\{(.+?)\}\})')
+FORMAT_REGEX = re.compile(r'(?:#\{(.+?)\})|(?:\{\{(.+?)\}\})')
+RSTRIP_REGEXP = re.compile(r'\n[ \t]*$')
+VARNAME_REGEXP = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
+# Attribute name used outside the context of the QWeb.
+SPECIAL_DIRECTIVES = {'t-translation', 't-ignore', 't-title'}
+# Name of the variable to insert the content in t-call in the template.
+# The slot will be replaced by the `t-call` tag content of the caller.
+T_CALL_SLOT = '0'
-RE_RSTRIP = re.compile(r'\n[ \t]*$')
-
+def indent_code(code, level):
+ """Indent the code to respect the python syntax."""
+ return textwrap.indent(textwrap.dedent(code).strip(), ' ' * 4 * level)
####################################
### QWebException ###
@@ -68,8 +447,7 @@ RE_RSTRIP = re.compile(r'\n[ \t]*$')
class QWebException(Exception):
""" Management of errors that raised when rendering a QWeb template.
"""
- def __init__(self, message, qweb, options, error=None, template=None, path=None, code=None):
- self.error = error
+ def __init__(self, message, qweb, options, template=None, path=None, code=None):
self.name = template
self.code = code if options and options.get('dev_mode') else None
self.path = path
@@ -78,32 +456,28 @@ class QWebException(Exception):
element = qweb._get_template(template, options)[0]
nodes = element.getroottree().xpath(self.path)
if nodes:
- node = nodes[0]
- node[:] = []
- node.text = None
+ node = etree.Element(nodes[0].tag, nodes[0].attrib)
self.html = etree.tostring(node, encoding='unicode')
self.stack = traceback.format_exc()
-
- self.message = message
- if self.error is not None:
- self.message = "%s\n%s: %s" % (self.message, self.error.__class__.__name__, self.error)
- if self.name is not None:
- self.message = "%s\nTemplate: %s" % (self.message, self.name)
- if self.path is not None:
- self.message = "%s\nPath: %s" % (self.message, self.path)
- if self.html is not None:
- self.message = "%s\nNode: %s" % (self.message, self.html)
-
- super(QWebException, self).__init__(message)
+ self.title = message
+ super().__init__(message)
def __str__(self):
- message = "%s\n%s\n%s" % (self.error, self.stack, self.message)
+ parts = [self.title]
+ if self.__cause__ is not None:
+ parts.append(f"{self.__cause__.__class__.__name__}: {self.__cause__}")
+ if self.name is not None:
+ parts.append(f"Template: {self.name}")
+ if self.path is not None:
+ parts.append(f"Path: {self.path}")
+ if self.html is not None:
+ parts.append(f"Node: {self.html}")
if self.code is not None:
- message = "%s\nCompiled code:\n%s" % (message, self.code)
- return message
+ parts.append(f"Compiled code:\n{self.code}")
+ return "\n".join(parts)
def __repr__(self):
- return f"QWebException({repr(self.message)})"
+ return f"QWebException({self.title!r})"
####################################
### QWeb ###
@@ -122,13 +496,7 @@ class IrQWeb(models.AbstractModel):
_name = 'ir.qweb'
_description = 'Qweb'
- _void_elements = frozenset([
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen',
- 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'])
_name_gen = count()
- # _available_objects builtins is not security safe (it's dangerous), is overridden by ir_qweb to only expose the safe_eval builtins.
- _available_objects = dict(_BUILTINS)
- _allowed_keyword = ['False', 'None', 'True', 'and', 'as', 'elif', 'else', 'for', 'if', 'in', 'is', 'not', 'or']
@QwebTracker.wrap_render
@api.model
@@ -150,6 +518,9 @@ class IrQWeb(models.AbstractModel):
compile_options = dict(self.env.context, dev_mode='qweb' in tools.config['dev_mode'])
compile_options.update(options)
+ if values and T_CALL_SLOT in values:
+ raise ValueError('values[0] should be unset when call the _render method and only set into the template.')
+
render_template = self._compile(template, compile_options)
rendering = render_template(self, values or {})
result = ''.join(rendering)
@@ -178,13 +549,12 @@ class IrQWeb(models.AbstractModel):
options = {}
element, document, ref = self._get_template(template, options)
- if not ref:
- ref = element.get('t-name', str(document))
+ element.attrib.pop('t-name', None)
# reference to get xml and etree (usually the template ID)
options['ref'] = ref
# str xml of the reference template used for compilation. Useful for debugging, dev mode and profiling.
- options['ref_xml'] = document if isinstance(document, str) else str(document, 'utf-8')
+ options['ref_xml'] = document
_options = dict(options)
options = frozendict(options)
@@ -203,42 +573,45 @@ class IrQWeb(models.AbstractModel):
# generate code
- def_name = f"template_{ref}" if isinstance(ref, int) else "template"
+ def_name = f'template_{ref}' if isinstance(ref, int) else 'template'
try:
+ if element.text:
+ element.text = re.compile(r'^(\n[ \t]*)+(\n[ \t])').sub(r'\2', element.text)
+
_options['_text_concat'] = []
- self._appendText("", _options) # To ensure the template function is a generator and doesn't become a regular function
- code_lines = ([f"def {def_name}(self, values, log):"] +
- self._compile_node(element, _options, 1) +
- self._flushText(_options, 1))
- except QWebException as e:
- raise e
+ self._append_text("", _options) # To ensure the template function is a generator and doesn't become a regular function
+ code_lines = (
+ [f'def {def_name}(self, compile_options, values, log):']
+ + self._compile_node(element, _options, 1)
+ + self._flush_text(_options, 1, rstrip=True)
+ )
+ except QWebException:
+ raise
except Exception as e:
raise QWebException("Error when compiling xml template",
- self, options, e, template, path=_options.get('last_path_node')) from e
+ self, options, template, path=_options.get('last_path_node')) from e
try:
code = '\n'.join(code_lines)
- except QWebException as e:
- raise e
except Exception as e:
code = '\n'.join(map(str, code_lines))
raise QWebException("Error when compiling xml template",
- self, options, e, template, code=code) from e
+ self, options, template, code=code) from e
# compile code and defined default values
try:
# noinspection PyBroadException
compiled = compile(code, f'<{def_name}>', 'exec')
- globals_dict = self._prepare_globals({}, options)
+ globals_dict = self._prepare_globals()
globals_dict['__builtins__'] = globals_dict # So that unknown/unsafe builtins are never added.
unsafe_eval(compiled, globals_dict)
compiled_fn = globals_dict[def_name]
- except QWebException as e:
- raise e
+ except QWebException:
+ raise
except Exception as e:
raise QWebException("Error when compiling xml template",
- self, options, e, template, code=code) from e
+ self, options, template, code=code) from e
# return the wrapped function
@@ -246,12 +619,12 @@ class IrQWeb(models.AbstractModel):
try:
log = {'last_path_node': None}
values = self._prepare_values(values, options)
- yield from compiled_fn(self, values, log)
+ yield from compiled_fn(self, options, values, log)
except (QWebException, TransactionRollbackError) as e:
- raise e
+ raise
except Exception as e:
- raise QWebException("Error when render the template",
- self, options, e, template, path=log.get('last_path_node'), code=code) from e
+ raise QWebException("Error while render the template",
+ self, options, template, path=log.get('last_path_node'), code=code) from e
return render_template
@@ -268,69 +641,81 @@ class IrQWeb(models.AbstractModel):
the rendering is frozen)
``load`` (function) overrides the load method
"""
- try:
- template = int(template)
- except:
- pass
-
- ref = template
+ # template is an xml etree already
if isinstance(template, etree._Element):
element = template
- document = etree.tostring(template)
- return (element, document, template.get('t-name'))
+ document = etree.tostring(template, encoding='unicode')
+ ref = None
+
+ # template is xml as string
+ elif isinstance(template, str) and template.startswith('<'):
+ element = etree.fromstring(template)
+ document = template
+ ref = None
+
+ # template is (id or ref) to a database stored template
else:
try:
- loaded = options.get('load', self._load)(template, options)
- if not loaded:
- raise ValueError("Can not load template '%s'" % template)
- document, ref = loaded
- except QWebException as e:
- raise e
- except Exception as e:
- template = options.get('caller_template', template)
- raise QWebException("load could not load template",
- self, options, e, template) from e
+ ref_alias = int(template) # e.g.
+ except ValueError:
+ ref_alias = template # e.g. web.layout
- if document is None:
- raise QWebException("Template not found",
- self, options, template=template)
+ doc_or_elem, ref = options.get('load', self._load)(ref_alias, options) or (None, None)
+ if doc_or_elem is None:
+ raise ValueError(f"Can not load template: {ref_alias!r}")
+ if isinstance(doc_or_elem, etree._Element):
+ element = doc_or_elem
+ document = etree.tostring(doc_or_elem, encoding='unicode')
+ elif isinstance(doc_or_elem, str):
+ element = etree.fromstring(doc_or_elem)
+ document = doc_or_elem
+ else:
+ raise TypeError(f"Loaded template {ref!r} should be a string.")
- if isinstance(document, etree._Element):
- element = document
- document = etree.tostring(document, encoding='utf-8')
- elif not document.strip().startswith('<') and os.path.exists(document):
- element = etree.parse(document).getroot()
- else:
- element = etree.fromstring(document)
+ # return etree, document and ref, or try to find the ref
+ if ref:
+ return (element, document, ref)
- for node in element:
- if node.get('t-name') == str(template):
+ #
+ #
+ #
+ #
+ for node in element.iter():
+ ref = node.get('t-name')
+ if ref:
return (node, document, ref)
- return (element, document, ref)
- def _load(self, name, options):
- lang = options.get('lang', get_lang(self.env).code)
- env = self.env
- if lang != env.context.get('lang'):
- env = env(context=dict(env.context, lang=lang))
+ # use the document itself as ref when no t-name was found
+ return (element, document, document)
- view_id = self.env['ir.ui.view'].get_view_id(name)
- template = env['ir.ui.view'].sudo()._read_template(view_id)
+ def _load(self, ref, options):
+ """
+ Load the template referenced by ``ref``.
- # QWeb's `_read_template` will check if one of the first children of
- # what we send to it has a "t-name" attribute having `name` as value
+ :returns: The loaded template (as string or etree) and its
+ identifier
+ :rtype: Optional[Tuple[Union[etree, str], Optional[str, int]]]
+ """
+ lang = options.get('lang') or get_lang(self.env).code
+ view_id = self.env['ir.ui.view'].get_view_id(ref)
+ template = self.env['ir.ui.view'].with_context(lang=lang).sudo()._read_template(view_id)
+
+ # QWeb's ``_read_template`` will check if one of the first children of
+ # what we send to it has a "t-name" attribute having ``ref`` as value
# to consider it has found it. As it'll never be the case when working
# with view ids or children view or children primary views, force it here.
- def is_child_view(view_name):
- view_id = self.env['ir.ui.view'].get_view_id(view_name)
+ def is_child_view(view_ref):
+ view_id = self.env['ir.ui.view'].get_view_id(view_ref)
view = self.env['ir.ui.view'].sudo().browse(view_id)
return view.inherit_id is not None
- if isinstance(name, int) or is_child_view(name):
+ if isinstance(ref, int) or is_child_view(ref):
view = etree.fromstring(template)
for node in view:
if node.get('t-name'):
- node.set('t-name', str(name))
+ node.set('t-name', str(ref))
+ node.attrib.pop('name', None)
+ node.attrib.pop('id', None)
return (view, view_id)
else:
return (template, view_id)
@@ -351,55 +736,59 @@ class IrQWeb(models.AbstractModel):
values['request'] = request
return values
- def _prepare_globals(self, globals_dict, options):
- """ Prepare the global context that will sent to eval the qweb generated
- code.
-
- :param globals_dict: template global values use in compiled code
- :param options: frozen dict of compilation parameters.
+ def _prepare_globals(self):
+ """ Prepare the global context that will sent to eval the qweb
+ generated code.
"""
- globals_dict['Sized'] = Sized
- globals_dict['Mapping'] = Mapping
- globals_dict['Markup'] = Markup
- globals_dict['escape'] = escape
- globals_dict['compile_options'] = options
- globals_dict.update(self._available_objects)
- return globals_dict
+ return {
+ 'Sized': Sized,
+ 'Mapping': Mapping,
+ 'Markup': Markup,
+ 'escape': escape,
+ 'VOID_ELEMENTS': VOID_ELEMENTS,
+ **_BUILTINS,
+ }
# helpers for compilation
- def _appendText(self, text, options):
+ def _append_text(self, text, options):
""" Add an item (converts to a string) to the list.
This will be concatenated and added during a call to the
- `_flushText` method. This makes it possible to return only one
+ `_flush_text` method. This makes it possible to return only one
yield containing all the parts."""
options['_text_concat'].append(self._compile_to_str(text))
- def _stripText(self, options):
+ def _rstrip_text(self, options):
+ """ The text to flush is right stripped, and the stripped content are
+ returned.
+ """
text_concat = options['_text_concat']
if not text_concat:
return ''
- result = RE_RSTRIP.search(text_concat[-1])
- if not result:
- return ''
- return result.group(0)
- def _flushText(self, options, indent, rstrip=False):
- """Concatenate all the textual chunks added by the `_appendText`
- method into a single yield."""
+ result = RSTRIP_REGEXP.search(text_concat[-1])
+ strip = result.group(0) if result else ''
+ text_concat[-1] = RSTRIP_REGEXP.sub('', text_concat[-1])
+
+ return strip
+
+ def _flush_text(self, options, level, rstrip=False):
+ """Concatenate all the textual chunks added by the `_append_text`
+ method into a single yield.
+ If no text to flush, return an empty list
+
+ If rstrip the text is right stripped.
+
+ @returns list(str)
+ """
text_concat = options['_text_concat']
- if text_concat:
- if rstrip:
- text_concat[-1] = RE_RSTRIP.sub('', text_concat[-1])
- text = ''.join(text_concat)
- text_concat.clear()
- return [f"{' ' * indent}yield {repr(text)}"]
- else:
+ if not text_concat:
return []
-
- def _indent(self, code, indent):
- """Indent the code to respect the python syntax."""
- return _indent(code, ' ' * indent)
+ if rstrip:
+ self._rstrip_text(options)
+ text = ''.join(text_concat)
+ text_concat.clear()
+ return [f"{' ' * level}yield {text!r}"]
def _make_name(self, prefix='var'):
"""Generates a unique name."""
@@ -410,7 +799,10 @@ class IrQWeb(models.AbstractModel):
are no t-* attributes), does not require dynamic rendering for its
attributes.
"""
- return el.tag != 't' and not any(att.startswith('t-') and att not in ['t-tag', 't-inner-content'] for att in el.attrib)
+ return el.tag != 't' and 'groups' not in el.attrib and not any(
+ att.startswith('t-') and att not in ('t-tag-open', 't-inner-content')
+ for att in el.attrib
+ )
# compile python expression and format string
@@ -419,24 +811,16 @@ class IrQWeb(models.AbstractModel):
expression python, uses string with format method.
Use format is faster to concat string and values.
"""
- text = ''
- values = []
- base_idx = 0
- for m in _FORMAT_REGEX.finditer(expr):
- literal = expr[base_idx:m.start()]
- if literal:
- text += literal.replace('{', '{{').replace("}", "}}")
- text += '{}'
- values.append(f'self._compile_to_str({self._compile_expr(m.group(1) or m.group(2))})')
- base_idx = m.end()
- # string past last regex match
- literal = expr[base_idx:]
- if literal:
- text += literal.replace('{', '{{').replace("}", "}}")
-
- code = repr(text)
+ #
+ # =>
+ # values['name'] = 'Hello %s %%s !' % (values['world'],)
+ values = [
+ f'self._compile_to_str({self._compile_expr(m.group(1) or m.group(2))})'
+ for m in FORMAT_REGEX.finditer(expr)
+ ]
+ code = repr(FORMAT_REGEX.sub('%s', expr.replace('%', '%%')))
if values:
- code += f'.format({", ".join(values)})'
+ code += f' % ({", ".join(values)},)'
return code
def _compile_expr_tokens(self, tokens, allowed_keys, argument_names=None, raise_on_missing=False):
@@ -462,24 +846,21 @@ class IrQWeb(models.AbstractModel):
# to avoid the risk of confusion between nested lambdas / comprehensions,
# this is currently performed independently at each level of brackets
# nesting (hence the function being recursive).
- index = 0
open_bracket_index = -1
bracket_depth = 0
argument_name = '_arg_%s__'
argument_names = argument_names or []
- while index < len(tokens):
- t = tokens[index]
+ for index, t in enumerate(tokens):
if t.exact_type in [token.LPAR, token.LSQB, token.LBRACE]:
bracket_depth += 1
- if t.exact_type in [token.RPAR, token.RSQB, token.RBRACE]:
+ elif t.exact_type in [token.RPAR, token.RSQB, token.RBRACE]:
bracket_depth -= 1
elif bracket_depth == 0 and t.exact_type == token.NAME:
string = t.string
if string == 'lambda': # lambda => allowed values for the current bracket depth
- i = index + 1
- while i < len(tokens):
+ for i in range(index + 1, len(tokens)):
t = tokens[i]
if t.exact_type == token.NAME:
argument_names.append(t.string)
@@ -491,10 +872,8 @@ class IrQWeb(models.AbstractModel):
raise NotImplementedError('Lambda default values are not supported')
else:
raise NotImplementedError('This lambda code style is not implemented.')
- i += 1
elif string == 'for': # list comprehensions => allowed values for the current bracket depth
- i = index + 1
- while len(tokens) > i:
+ for i in range(index + 1, len(tokens)):
t = tokens[i]
if t.exact_type == token.NAME:
if t.string == 'in':
@@ -504,9 +883,6 @@ class IrQWeb(models.AbstractModel):
pass
else:
raise NotImplementedError('This loop code style is not implemented.')
- i += 1
-
- index += 1
# Use bracket to nest structures.
# Recursively processes the "sub-scopes", and replace their content with
@@ -546,7 +922,7 @@ class IrQWeb(models.AbstractModel):
# key.attrib, or an index key[0] ...)
code = []
index = 0
- pos = tokens and tokens[0].start # to keep indent when use expr on multi line
+ pos = tokens and tokens[0].start # to keep level when use expr on multi line
while index < len(tokens):
t = tokens[index]
string = t.string
@@ -587,10 +963,10 @@ class IrQWeb(models.AbstractModel):
# Should have values['product'].price to raise an error when get
# the 'product' value and not an 'NoneType' object has no
# attribute 'price' error.
- code.append(f'values[{repr(string)}]')
+ code.append(f'values[{string!r}]')
else:
# not assignation allowed, only getter
- code.append(f'values.get({repr(string)})')
+ code.append(f'values.get({string!r})')
elif t.type not in [tokenize.ENCODING, token.ENDMARKER, token.DEDENT]:
code.append(string)
@@ -608,14 +984,23 @@ class IrQWeb(models.AbstractModel):
adding the namepaces for the dynamic values.
This method tokenize the string and call ``_compile_expr_tokens``
method.
+
+ :param expr: string: python expression
+ :param [raise_on_missing]: boolean:
+ Compile has `values['product'].price` instead of
+ `values.get('product').price` to raise an error when get the
+ 'product' value and not an 'NoneType' object has no attribute
+ 'price' error.
"""
- readable = io.BytesIO(f"({expr or ''})".strip().encode('utf-8'))
+ # Parentheses are useful for compiling multi-line expressions such as
+ # conditions existing in some templates. (see test_compile_expr tests)
+ readable = io.BytesIO(f"({expr or ''})".encode('utf-8'))
try:
tokens = list(tokenize.tokenize(readable.readline))
except tokenize.TokenError:
raise ValueError(f"Can not compile expression: {expr}")
- expression = self._compile_expr_tokens(tokens, self._allowed_keyword + list(self._available_objects.keys()), raise_on_missing=raise_on_missing)
+ expression = self._compile_expr_tokens(tokens, ALLOWED_KEYWORD, raise_on_missing=raise_on_missing)
assert_valid_codeobj(_SAFE_QWEB_OPCODES, compile(expression, '<>', 'eval'), expr)
@@ -655,22 +1040,27 @@ class IrQWeb(models.AbstractModel):
then this method should return ``['foreach', 'if']``.
"""
return [
+ 'elif', # Must be the first because compiled by the previous if.
+ 'else', # Must be the first because compiled by the previous if.
'debug',
'groups',
- 'foreach',
- 'if', 'elif', 'else',
+ 'as', 'foreach',
+ 'if',
'call-assets',
- 'field', 'esc', 'raw', 'out',
- 'tag',
'lang',
+ 'options',
+ 'att',
+ 'field', 'esc', 'raw', 'out',
+ 'tag-open',
'call',
'set',
'inner-content',
+ 'tag-close',
]
# compile
- def _compile_node(self, el, options, indent):
+ def _compile_node(self, el, options, level):
""" Compile the given element into python code.
The t-* attributes (directives) will be converted to a python instruction. If there
@@ -684,30 +1074,49 @@ class IrQWeb(models.AbstractModel):
:return: list of string
"""
- if el.get("groups"):
- el.set("t-groups", el.attrib.pop("groups"))
+ # Internal directive used to skip a rendering.
+ if 't-qweb-skip' in el.attrib:
+ return []
# if tag don't have qweb attributes don't use directives
if self._is_static_node(el, options):
- return self._compile_static_node(el, options, indent)
+ return self._compile_static_node(el, options, level)
path = options['root'].getpath(el)
if options['last_path_node'] != path:
options['last_path_node'] = path
- body = [self._indent(f'log["last_path_node"] = {repr(path)}', indent)]
+ body = [indent_code(f'log["last_path_node"] = {path!r}', level)]
else:
body = []
# create an iterator on directives to compile in order
- options['iter_directives'] = iter(self._directives_eval_order() + [None])
+ options['iter_directives'] = iter(self._directives_eval_order())
+
+ # add technical directive tag-open, tag-close, inner-content and take
+ # care of the namspace
+ if not el.nsmap:
+ unqualified_el_tag = el_tag = el.tag
+ else:
+ # Etree will remove the ns prefixes indirection by inlining the corresponding
+ # nsmap definition into the tag attribute. Restore the tag and prefix here.
+ # Note: we do not support namespace dynamic attributes, we need a default URI
+ # on the root and use attribute directive t-att="{'xmlns:example': value}".
+ unqualified_el_tag = etree.QName(el.tag).localname
+ el_tag = unqualified_el_tag
+ if el.prefix:
+ el_tag = f'{el.prefix}:{el_tag}'
+
+ if unqualified_el_tag != 't':
+ el.set('t-tag-open', el_tag)
+ if unqualified_el_tag not in VOID_ELEMENTS:
+ el.set('t-tag-close', el_tag)
- el.set('t-tag', el.tag)
if not ({'t-out', 't-esc', 't-raw', 't-field'} & set(el.attrib)):
el.set('t-inner-content', 'True')
- return body + self._compile_directives(el, options, indent)
+ return body + self._compile_directives(el, options, level)
- def _compile_static_node(self, el, options, indent):
+ def _compile_static_node(self, el, options, level):
""" Compile a purely static element into a list of string. """
if not el.nsmap:
unqualified_el_tag = el_tag = el.tag
@@ -750,181 +1159,85 @@ class IrQWeb(models.AbstractModel):
original_nsmap = dict(options['nsmap'])
if unqualified_el_tag != 't':
- attributes = ''.join(f' {str(name)}="{str(escape(str(value)))}"'
+ attributes = ''.join(f' {name}="{escape(str(value))}"'
for name, value in attrib.items() if value or isinstance(value, str))
- self._appendText(f'<{el_tag}{attributes}', options)
- if unqualified_el_tag in self._void_elements:
- self._appendText('/>', options)
+ self._append_text(f'<{el_tag}{"".join(attributes)}', options)
+ if unqualified_el_tag in VOID_ELEMENTS:
+ self._append_text('/>', options)
else:
- self._appendText('>', options)
+ self._append_text('>', options)
+
+ el.attrib.clear()
if el.nsmap:
options['nsmap'].update(el.nsmap)
- body = self._compile_directive_inner_content(el, options, indent)
+ body = self._compile_directive(el, options, 'inner-content', level)
options['nsmap'] = original_nsmap
else:
- body = self._compile_directive_inner_content(el, options, indent)
+ body = self._compile_directive(el, options, 'inner-content', level)
if unqualified_el_tag != 't':
- if unqualified_el_tag not in self._void_elements:
- self._appendText(f'{el_tag}>', options)
+ if unqualified_el_tag not in VOID_ELEMENTS:
+ self._append_text(f'{el_tag}>', options)
return body
- def _compile_attributes(self, options, indent):
- """Generates the part of the code that post-process the attributes
- (this is ``attrs`` in the compiled code) during rendering time.
- """
- # Use str(value) to change Markup into str and escape it, then use str
- # to avoid the escaping of the other html content.
- body = self._flushText(options, indent)
- body.append(self._indent(dedent("""
- attrs = self._post_processing_att(tagName, attrs, compile_options)
- for name, value in attrs.items():
- if value or isinstance(value, str):
- yield f' {str(escape(str(name)))}="{str(escape(str(value)))}"'
- """).strip(), indent))
- return body
-
- def _compile_static_attributes(self, el, options, indent):
- """ Compile the static and dynamc attributes of the given element.
-
- We do not support namespaced dynamic attributes.
- """
- # Etree will also remove the ns prefixes indirection in the attributes. As we only have
- # the namespace definition, we'll use an nsmap where the keys are the definitions and
- # the values the prefixes in order to get back the right prefix and restore it.
- nsprefixmap = {v: k for k, v in chain(options['nsmap'].items(), el.nsmap.items())}
-
- code = []
- for key, value in el.attrib.items():
- if not key.startswith('t-'):
- attrib_qname = etree.QName(key)
- if attrib_qname.namespace:
- key = f'{nsprefixmap[attrib_qname.namespace]}:{attrib_qname.localname}'
- code.append(self._indent(f'attrs[{repr(key)}] = {repr(value)}', indent))
- return code
-
- def _compile_dynamic_attributes(self, el, options, indent):
- """ Compile the dynamic attributes of the given element into a list
- string (this is adding elements to ``attrs`` in the compiled code).
-
- We do not support namespaced dynamic attributes.
- """
- code = []
- for name, value in el.attrib.items():
-
- if name.startswith('t-attf-'):
- code.append(self._indent(f"attrs[{repr(name[7:])}] = {self._compile_format(value)}", indent))
- elif name.startswith('t-att-'):
- code.append(self._indent(f"attrs[{repr(name[6:])}] = {self._compile_expr(value)}", indent))
- elif name == 't-att':
- code.append(self._indent(dedent(f"""
- atts_value = {self._compile_expr(value)}
- if isinstance(atts_value, dict):
- attrs.update(atts_value)
- elif isinstance(atts_value, (list, tuple)) and not isinstance(atts_value[0], (list, tuple)):
- attrs.update([atts_value])
- elif isinstance(atts_value, (list, tuple)):
- attrs.update(dict(atts_value))
- """), indent))
- return code
-
- def _compile_all_attributes(self, el, options, indent, attr_already_created=False):
- """ Compile the attributes (static and dynamic) of the given elements
- into a list of str.
- (this compiled The code will create the ``attrs`` in the compiled code).
- """
- code = []
- if any(name.startswith('t-att') or not name.startswith('t-') for name, value in el.attrib.items()):
- if not attr_already_created:
- attr_already_created = True
- code.append(self._indent("attrs = {}", indent))
- code.extend(self._compile_static_attributes(el, options, indent))
- code.extend(self._compile_dynamic_attributes(el, options, indent))
- if attr_already_created:
- code.append(self._indent(f"tagName = {repr(el.tag)}", indent))
- code.extend(self._compile_attributes(options, indent))
- return code
-
- def _compile_tag_open(self, el, options, indent, attr_already_created=False):
- """ Compile the opening tag of the given element into a list of string. """
- extra_attrib = {}
- if not el.nsmap:
- unqualified_el_tag = el_tag = el.tag
- else:
- # Etree will remove the ns prefixes indirection by inlining the corresponding
- # nsmap definition into the tag attribute. Restore the tag and prefix here.
- # Note: we do not support namespace dynamic attributes, we need a default URI
- # on the root and use attribute directive t-att="{'xmlns:example': value}".
- unqualified_el_tag = etree.QName(el.tag).localname
- el_tag = unqualified_el_tag
- if el.prefix:
- el_tag = f'{el.prefix}:{el_tag}'
-
- # If `el` introduced new namespaces, write them as attribute by using the
- # `extra_attrib` dict.
- for ns_prefix, ns_definition in set(el.nsmap.items()) - set(options['nsmap'].items()):
- if ns_prefix is None:
- extra_attrib['xmlns'] = ns_definition
- else:
- extra_attrib[f'xmlns:{ns_prefix}'] = ns_definition
-
- code = []
- if unqualified_el_tag != 't':
- attributes = ''.join(f' {str(name)}="{str(escape(self._compile_to_str(value)))}"'
- for name, value in extra_attrib.items())
- self._appendText("<{}{}".format(el_tag, attributes), options)
- code.extend(self._compile_all_attributes(el, options, indent, attr_already_created))
- if unqualified_el_tag in self._void_elements:
- self._appendText('/>', options)
- else:
- self._appendText('>', options)
-
- return code
-
- def _compile_tag_close(self, el, options):
- """ Compile the closing tag of the given element into a list of string. """
- if not el.nsmap:
- unqualified_el_tag = el_tag = el.tag
- else:
- unqualified_el_tag = etree.QName(el.tag).localname
- el_tag = unqualified_el_tag
- if el.prefix:
- el_tag = f'{el.prefix}:{el_tag}'
-
- if unqualified_el_tag != 't' and el_tag not in self._void_elements:
- self._appendText(f'{el_tag}>', options)
- return []
-
- def _compile_directives(self, el, options, indent):
+ def _compile_directives(self, el, options, level):
""" Compile the given element, following the directives given in the
iterator ``options['iter_directives']`` create by `_compile_node``
method.
:return: list of code lines
"""
-
if self._is_static_node(el, options):
- el.attrib.pop('t-tag', None)
+ el.attrib.pop('t-tag-open', None)
el.attrib.pop('t-inner-content', None)
- return self._compile_static_node(el, options, indent)
+ el.attrib.pop('t-tag-close', None)
+ return self._compile_static_node(el, options, level)
- # compile the first directive present on the element
+ code = []
+
+ # compile the directives still present on the element
for directive in options['iter_directives']:
if ('t-' + directive) in el.attrib:
- return self._compile_directive(el, options, directive, indent)
+ code.extend(self._compile_directive(el, options, directive, level))
+ elif directive == 'groups':
+ if directive in el.attrib:
+ code.extend(self._compile_directive(el, options, directive, level))
+ elif directive == 'att':
+ if any(name.startswith('t-att-') or
+ name.startswith('t-attf-') or
+ not name.startswith('t-')
+ for name in el.attrib):
+ code.extend(self._compile_directive(el, options, directive, level))
+ elif directive == 'options':
+ if any(name.startswith('t-options-') for name in el.attrib):
+ code.extend(self._compile_directive(el, options, directive, level))
+ elif directive == 'set':
+ if any(name.startswith('t-set-') or
+ name.startswith('t-setf-')
+ for name in el.attrib):
+ code.extend(self._compile_directive(el, options, directive, level))
- return []
+ # compile unordered directives still present on the element
+ for att in el.attrib:
+ if att not in SPECIAL_DIRECTIVES and att.startswith('t-') and getattr(self, f"_compile_directive_{att[2:].replace('-', '_')}", None):
+ code.extend(self._compile_directive(el, options, directive, level))
+
+ remaining = set(el.attrib) - SPECIAL_DIRECTIVES
+ if remaining:
+ _logger.warning('Unknown directives or unused attributes: %s in %s', remaining, options['template'])
+
+ return code
@QwebTracker.wrap_compile_directive
- def _compile_directive(self, el, options, directive, indent):
+ def _compile_directive(self, el, options, directive, level):
compile_handler = getattr(self, f"_compile_directive_{directive.replace('-', '_')}", None)
- return compile_handler(el, options, indent)
+ return compile_handler(el, options, level)
# compile directives
- def _compile_directive_debug(self, el, options, indent):
+ def _compile_directive_debug(self, el, options, level):
"""Compile `t-debug` expressions into a python code as a list of
strings.
@@ -934,59 +1247,174 @@ class IrQWeb(models.AbstractModel):
debugger = el.attrib.pop('t-debug')
code = []
if options.get('dev_mode'):
- code.append(self._indent(f"self._debug_trace({repr(debugger)}, compile_options)", indent))
+ code.append(indent_code(f"self._debug_trace({debugger!r}, compile_options)", level))
else:
_logger.warning("@t-debug in template is only available in qweb dev mode options")
- code.extend(self._compile_directives(el, options, indent))
return code
- def _compile_directive_options(self, el, options, indent):
+ def _compile_directive_options(self, el, options, level):
"""
- compile t-options and add to the dict the t-options-xxx values
+ compile t-options and add to the dict the t-options-xxx. Will create
+ the dictionary ``values['__qweb_options__']`` in compiled code.
"""
- varname = options.get('t_options_varname', 't_options')
code = []
- dict_arg = []
+ dict_options = []
for key in list(el.attrib):
if key.startswith('t-options-'):
value = el.attrib.pop(key)
option_name = key[10:]
- dict_arg.append(f'{repr(option_name)}:{self._compile_expr(value)}')
+ dict_options.append(f'{option_name!r}:{self._compile_expr(value)}')
t_options = el.attrib.pop('t-options', None)
- if t_options and dict_arg:
- code.append(self._indent(f"{varname} = {{**{self._compile_expr(t_options)}, {', '.join(dict_arg)}}}", indent))
- elif dict_arg:
- code.append(self._indent(f"{varname} = {{{', '.join(dict_arg)}}}", indent))
+ if t_options and dict_options:
+ code.append(indent_code(f"values['__qweb_options__'] = {{**{self._compile_expr(t_options)}, {', '.join(dict_options)}}}", level))
+ elif dict_options:
+ code.append(indent_code(f"values['__qweb_options__'] = {{{', '.join(dict_options)}}}", level))
elif t_options:
- code.append(self._indent(f"{varname} = {self._compile_expr(t_options)}", indent))
-
- return code
-
- def _compile_directive_tag(self, el, options, indent):
- """Compile the element tag into a python code as a list of strings.
-
- The code will contains the opening tag, namespace, static and dynamic
- attributes and closing tag.
- """
- el.attrib.pop('t-tag', None)
-
- code = self._compile_tag_open(el, options, indent, False)
-
- # Update the dict of inherited namespaces before continuing the recursion. Note:
- # since `options['nsmap']` is a dict (and therefore mutable) and we do **not**
- # want changes done in deeper recursion to bevisible in earlier ones, we'll pass
- # a copy before continuing the recursion and restore the original afterwards.
- if el.nsmap:
- code.extend(self._compile_directives(el, dict(options, nsmap=el.nsmap), indent))
+ code.append(indent_code(f"values['__qweb_options__'] = {self._compile_expr(t_options)}", level))
else:
- code.extend(self._compile_directives(el, options, indent))
+ code.append(indent_code("values['__qweb_options__'] = {}", level))
- code.extend(self._compile_tag_close(el, options))
+ el.set('t-consumed-options', str(bool(code)))
return code
- def _compile_directive_set(self, el, options, indent):
+ def _compile_directive_consumed_options(self, el, options, level):
+ raise SyntaxError('the t-options must be on the same tag as a directive that consumes it (for example: t-out, t-field, t-call)')
+
+ def _compile_directive_att(self, el, options, level):
+ """ Compile the attributes of the given elements.
+
+ The compiled function will create the ``values['__qweb_attrs__']``
+ dictionary. Then the dictionary will be output.
+
+
+ The new namespaces of the current element.
+
+ The static attributes (not prefixed by ``t-``) are add to the
+ dictionary in first.
+
+ The dynamic attributes values will be add after. The dynamic
+ attributes has different origins.
+ - value from key equal to ``t-att``: python dictionary expression;
+ - value from keys that start with ``t-att-``: python expression;
+ - value from keys that start with ``t-attf-``: format string
+ expression.
+ """
+ code = [indent_code("attrs = values['__qweb_attrs__'] = {}", level)]
+
+ # Compile the introduced new namespaces of the given element.
+ #
+ # Add the found new attributes into the `attrs` dictionary like
+ # the static attributes.
+ if el.nsmap:
+ for ns_prefix, ns_definition in set(el.nsmap.items()) - set(options['nsmap'].items()):
+ key = 'xmlns'
+ if ns_prefix is not None:
+ key = f'xmlns:{ns_prefix}'
+ code.append(indent_code(f'attrs[{key!r}] = {ns_definition!r}', level))
+
+ # Compile the static attributes of the given element.
+ #
+ # Etree will also remove the ns prefixes indirection in the
+ # attributes. As we only have the namespace definition, we'll use
+ # an nsmap where the keys are the definitions and the values the
+ # prefixes in order to get back the right prefix and restore it.
+ if any(not name.startswith('t-') for name in el.attrib):
+ nsprefixmap = {v: k for k, v in chain(options['nsmap'].items(), el.nsmap.items())}
+ for key in list(el.attrib):
+ if not key.startswith('t-'):
+ value = el.attrib.pop(key)
+ attrib_qname = etree.QName(key)
+ if attrib_qname.namespace:
+ key = f'{nsprefixmap[attrib_qname.namespace]}:{attrib_qname.localname}'
+ code.append(indent_code(f'attrs[{key!r}] = {value!r}', level))
+
+ # Compile the dynamic attributes of the given element. All
+ # attributes will be add to the ``attrs`` dictionary in the
+ # compiled function.
+ for key in list(el.attrib):
+ if key.startswith('t-attf-'):
+ value = el.attrib.pop(key)
+ code.append(indent_code(f"attrs[{key[7:]!r}] = {self._compile_format(value)}", level))
+ elif key.startswith('t-att-'):
+ value = el.attrib.pop(key)
+ code.append(indent_code(f"attrs[{key[6:]!r}] = {self._compile_expr(value)}", level))
+ elif key == 't-att':
+ value = el.attrib.pop(key)
+ code.append(indent_code(f"""
+ atts_value = {self._compile_expr(value)}
+ if isinstance(atts_value, dict):
+ attrs.update(atts_value)
+ elif isinstance(atts_value, (list, tuple)) and not isinstance(atts_value[0], (list, tuple)):
+ attrs.update([atts_value])
+ elif isinstance(atts_value, (list, tuple)):
+ attrs.update(dict(atts_value))
+ """, level))
+
+ return code
+
+ def _compile_directive_tag_open(self, el, options, level):
+ """ Compile the opening tag with attributes of the given element into
+ a list of python code line.
+
+ The compiled function will fill the ``attrs`` dictionary. Then the
+ ``attrs`` dictionary will be output and reset the value of ``attrs``.
+
+ The static attributes (not prefixed by ``t-``) are add to the
+ ``attrs`` dictionary in first.
+
+ The dynamic attributes values will be add after. The dynamic
+ attributes has different origins.
+ - value from key equal to ``t-att``: python dictionary expression;
+ - value from keys that start with ``t-att-``: python expression;
+ - value from keys that start with ``t-attf-``: format string
+ expression.
+ """
+
+ el_tag = el.attrib.pop('t-tag-open', None)
+ if not el_tag:
+ return []
+
+ # open the open tag
+ self._append_text(f"<{el_tag}", options)
+
+ code = self._flush_text(options, level)
+
+ # Generates the part of the code that prost process and output the
+ # attributes from ``attrs`` dictionary. Consumes `attrs` dictionary
+ # and reset it.
+ #
+ # Use str(value) to change Markup into str and escape it, then use str
+ # to avoid the escaping of the other html content.
+ code.append(indent_code(f"""
+ attrs = values.pop('__qweb_attrs__', None)
+ if attrs:
+ tagName = {el.tag!r}
+ attrs = self._post_processing_att(tagName, attrs, compile_options)
+ for name, value in attrs.items():
+ if value or isinstance(value, str):
+ yield f' {{escape(str(name))}}="{{escape(str(value))}}"'
+ """, level))
+
+ # close the open tag
+ if 't-tag-close' in el.attrib:
+ self._append_text('>', options)
+ else:
+ self._append_text('/>', options)
+
+ return code
+
+ def _compile_directive_tag_close(self, el, options, level):
+ """ Compile the closing tag of the given element into string.
+ Returns an empty list because it's use only `_append_text`.
+ """
+ el_tag = el.attrib.pop("t-tag-close", None)
+ if el_tag:
+ self._append_text(f'{el_tag}>', options)
+ return []
+
+ def _compile_directive_set(self, el, options, level):
"""Compile `t-set` expressions into a python code as a list of
strings.
@@ -997,54 +1425,73 @@ class IrQWeb(models.AbstractModel):
The code will contain the assignment of the dynamically generated value.
"""
- varname = el.attrib.pop('t-set')
- code = self._flushText(options, indent, True)
- if 't-value' in el.attrib:
- if varname == '0':
- raise ValueError('t-set="0" should not contains t-value or t-valuef')
- expr = el.attrib.pop('t-value') or 'None'
- expr = self._compile_expr(expr)
- elif 't-valuef' in el.attrib:
- if varname == '0':
- raise ValueError('t-set="0" should not contains t-value or t-valuef')
- exprf = el.attrib.pop('t-valuef')
- expr = self._compile_format(exprf)
- else:
- # set the content as value
- def_name = self._make_name("qweb_t_set")
- content = self._compile_directive_inner_content(el, options, indent + 1) + self._flushText(options, indent + 1, True)
- if content:
- code.append(self._indent(f"def {def_name}():", indent))
- code.extend(content)
- expr = f"Markup(''.join({def_name}()))"
+ code = self._flush_text(options, level, rstrip=el.tag.lower() == 't')
+
+ if 't-set' in el.attrib:
+ varname = el.attrib.pop('t-set')
+ if varname == "":
+ raise KeyError('t-set')
+ if varname != T_CALL_SLOT and varname[0] != '{' and not VARNAME_REGEXP.match(varname):
+ raise ValueError('The varname can only contain alphanumeric characters and underscores.')
+
+ if 't-value' in el.attrib or 't-valuef' in el.attrib or varname[0] == '{':
+ el.attrib.pop('t-inner-content') # The content is considered empty.
+ if varname == T_CALL_SLOT:
+ raise SyntaxError('t-set="0" should not be set from t-value or t-valuef')
+
+ if 't-value' in el.attrib:
+ expr = el.attrib.pop('t-value') or 'None'
+ code.append(indent_code(f"values[{varname!r}] = {self._compile_expr(expr)}", level))
+ elif 't-valuef' in el.attrib:
+ exprf = el.attrib.pop('t-valuef')
+ code.append(indent_code(f"values[{varname!r}] = {self._compile_format(exprf)}", level))
+ elif varname[0] == '{':
+ code.append(indent_code(f"values.update({self._compile_expr(varname)})", level))
else:
- expr = "''"
+ # set the content as value
+ def_name = self._make_name("qweb_t_set")
+ content = (
+ self._compile_directive(el, options, 'inner-content', level + 1) +
+ self._flush_text(options, level + 1))
+ if content:
+ code.append(indent_code(f"def {def_name}(self, compile_options, values, log):", level))
+ code.extend(content)
+ expr = f"Markup(''.join({def_name}(self, compile_options, values, log)))"
+ else:
+ expr = "''"
+ code.append(indent_code(f"values[{varname!r}] = {expr}", level))
+
+ for key in list(el.attrib):
+ if key.startswith('t-set-') or key.startswith('t-setf-'):
+ expr = el.attrib.pop(key)
+ varname = key.split('-', 2)
+ if not VARNAME_REGEXP.match(varname[2]):
+ raise ValueError(f'The varname {varname[2]!r} can only contain alphanumeric characters and underscores.')
+ if varname[1] == 'set':
+ code.append(indent_code(f"values[{varname[2]!r}] = {self._compile_expr(expr)}", level))
+ else:
+ code.append(indent_code(f"values[{varname[2]!r}] = {self._compile_format(expr)}", level))
- code.append(self._indent(f"values[{repr(varname)}] = {expr}", indent))
return code
- def _compile_directive_value(self, el, options, indent):
+ def _compile_directive_value(self, el, options, level):
"""Compile `t-value` expressions into a python code as a list of strings.
This method only check if this attributes is on the same node of a
`t-set` attribute.
"""
- if not options.get('t-set') is None:
- raise ValueError("t-value must be on the same node of t-set")
- return []
+ raise SyntaxError("t-value must be on the same node of t-set")
- def _compile_directive_valuef(self, el, options, indent):
+ def _compile_directive_valuef(self, el, options, level):
"""Compile `t-valuef` expressions into a python code as a list of strings.
This method only check if this attributes is on the same node of a
`t-set` attribute.
"""
- if not options.get('t-set') is None:
- raise ValueError("t-valuef must be on the same node of t-set")
- return []
+ raise SyntaxError("t-valuef must be on the same node of t-set")
- def _compile_directive_inner_content(self, el, options, indent):
+ def _compile_directive_inner_content(self, el, options, level):
"""Compiles the content of the element (is the technical `t-inner-content`
directive created by QWeb) into a python code as a list of
strings.
@@ -1054,109 +1501,140 @@ class IrQWeb(models.AbstractModel):
"""
el.attrib.pop('t-inner-content', None)
+ if el.nsmap:
+ # Update the dict of inherited namespaces before continuing the recursion. Note:
+ # since `options['nsmap']` is a dict (and therefore mutable) and we do **not**
+ # want changes done in deeper recursion to bevisible in earlier ones, we'll pass
+ # a copy before continuing the recursion and restore the original afterwards.
+ options = dict(options, nsmap=el.nsmap)
+
if el.text is not None:
- self._appendText(el.text, options)
+ self._append_text(el.text, options)
body = []
- if el.getchildren():
- for item in el:
- if isinstance(item, etree._Comment):
- if options.get('preserve_comments'):
- self._appendText("" % item.text, options)
- else:
- body.extend(self._compile_node(item, options, indent))
- # comments can also contains tail text
- if item.tail is not None:
- self._appendText(item.tail, options)
+ for item in el:
+ if isinstance(item, etree._Comment):
+ if options.get('preserve_comments'):
+ self._append_text(f"", options)
+ else:
+ body.extend(self._compile_node(item, options, level))
+ # comments can also contains tail text
+ if item.tail is not None:
+ self._append_text(item.tail, options)
return body
- def _compile_directive_else(self, el, options, indent):
- """Compile `t-else` expressions into a python code as a list of strings.
-
- This method is linked with the `t-if` directive.
- The code will contain the compiled code of the element (without `else`
- python part).
- """
- if el.attrib.pop('t-else') == '_t_skip_else_':
- return []
- if not options.pop('t_if', None):
- raise ValueError("t-else directive must be preceded by t-if or t-elif directive")
- compiled = self._compile_directives(el, options, indent)
- el.attrib['t-else'] = '_t_skip_else_'
- return compiled
-
- def _compile_directive_elif(self, el, options, indent):
- """Compile `t-eif` expressions into a python code as a list of strings.
-
- This method is linked with the `t-if` directive.
- The code will contain the compiled code of the element (without `else`
- python part).
- """
- _elif = el.attrib['t-elif']
- if _elif == '_t_skip_else_':
- el.attrib.pop('t-elif')
- return []
- if not options.pop('t_if', None):
- raise ValueError("t-elif directive must be preceded by t-if or t-elif directive")
- compiled = self._compile_directive_if(el, options, indent)
- el.attrib['t-elif'] = '_t_skip_else_'
- return compiled
-
- def _compile_directive_if(self, el, options, indent):
+ def _compile_directive_if(self, el, options, level):
"""Compile `t-if` expressions into a python code as a list of strings.
The code will contain the condition `if`, `else` and `elif` part that
wrap the rest of the compiled code of this element.
"""
- if 't-elif' in el.attrib:
- expr = el.attrib.pop('t-elif')
- else:
- expr = el.attrib.pop('t-if')
+ expr = el.attrib.pop('t-if', el.attrib.pop('t-elif', None))
- strip = self._stripText(options)
+ assert not expr.isspace(), 't-if or t-elif expression should not be empty.'
- code = self._flushText(options, indent, True)
- if el.tag.lower() != 't':
- self._appendText(strip, options)
- content_if = self._compile_directives(el, options, indent + 1) + self._flushText(options, indent + 1, True)
+ strip = self._rstrip_text(options)
+ code = self._flush_text(options, level)
- orelse = []
+ code.append(indent_code(f"if {self._compile_expr(expr)}:", level))
+ body = []
+ if strip and el.tag.lower() != 't':
+ self._append_text(strip, options)
+ body.extend(
+ self._compile_directives(el, options, level + 1) +
+ self._flush_text(options, level + 1, rstrip=True))
+ code.extend(body or [indent_code('pass', level + 1)])
+
+ # Look for the else or elif conditions
next_el = el.getnext()
comments_to_remove = []
while isinstance(next_el, etree._Comment):
comments_to_remove.append(next_el)
next_el = next_el.getnext()
+
+ # If there is a t-else directive, the comment nodes are deleted
+ # and the t-else or t-elif is validated.
if next_el is not None and {'t-else', 't-elif'} & set(next_el.attrib):
+ # Insert a flag to allow t-else or t-elif rendering.
+ next_el.attrib['t-else-valid'] = 'True'
+
+ # remove comment node
parent = el.getparent()
for comment in comments_to_remove:
parent.remove(comment)
if el.tail and not el.tail.isspace():
- raise ValueError("Unexpected non-whitespace characters between t-if and t-else directives")
+ raise SyntaxError("Unexpected non-whitespace characters between t-if and t-else directives")
el.tail = None
- if next_el.tag.lower() != 't':
- self._appendText(strip, options)
- orelse = self._compile_node(next_el, dict(options, t_if=True), indent + 1) + self._flushText(options, indent + 1, True)
- code.append(self._indent(f"if {self._compile_expr(expr)}:", indent))
- code.extend(content_if or [self._indent('pass', indent + 1)])
- if orelse:
- code.append(self._indent("else:", indent))
- code.extend(orelse)
+ # You have to render the `t-else` and `t-elif` here in order
+ # to be able to put the log. Otherwise, the parent's
+ # `t-inner-content`` directive will render the different
+ # nodes without taking indentation into account such as:
+ # if (if_expression):
+ # content_if
+ # log ['last_path_node'] = path
+ # else:
+ # content_else
+
+ code.append(indent_code("else:", level))
+ body = []
+ if strip and next_el.tag.lower() != 't':
+ self._append_text(strip, options)
+ body.extend(
+ self._compile_node(next_el, options, level + 1)+
+ self._flush_text(options, level + 1, rstrip=True))
+ code.extend(body or [indent_code('pass', level + 1)])
+
+ # Insert a flag to avoid the t-else or t-elif rendering when
+ # the parent t-inner-content dirrective compile his
+ # children.
+ next_el.attrib['t-qweb-skip'] = 'True'
+
return code
- def _compile_directive_groups(self, el, options, indent):
+ def _compile_directive_elif(self, el, options, level):
+ """Compile `t-eif` expressions into a python code as a list of
+ strings. This method is linked with the `t-if` directive.
+
+ Check if this directive is valide, the t-qweb-skip flag and call
+ `t-if` directive
+ """
+ if not el.attrib.pop('t-else-valid', None):
+ raise SyntaxError("t-elif directive must be preceded by t-if or t-elif directive")
+
+ return self._compile_directive_if(el, options, level)
+
+ def _compile_directive_else(self, el, options, level):
+ """Compile `t-else` expressions into a python code as a list of strings.
+ This method is linked with the `t-if` directive.
+
+ Check if this directive is valide and add the t-qweb-skip flag.
+ """
+ if not el.attrib.pop('t-else-valid', None):
+ raise SyntaxError("t-elif directive must be preceded by t-if or t-elif directive")
+ el.attrib.pop('t-else')
+ return []
+
+ def _compile_directive_groups(self, el, options, level):
"""Compile `t-groups` expressions into a python code as a list of
strings.
The code will contain the condition `if self.user_has_groups(groups)`
part that wrap the rest of the compiled code of this element.
"""
- groups = el.attrib.pop('t-groups')
- code = self._flushText(options, indent, True)
- code.append(self._indent(f"if self.user_has_groups({repr(groups)}):", indent))
- code.extend(self._compile_directives(el, options, indent + 1) + self._flushText(options, indent + 1, True) or [self._indent('pass', indent + 1)])
+ groups = el.attrib.pop('t-groups', el.attrib.pop('groups', None))
+
+ strip = self._rstrip_text(options)
+ code = self._flush_text(options, level)
+ code.append(indent_code(f"if self.user_has_groups({groups!r}):", level))
+ if strip and el.tag.lower() != 't':
+ self._append_text(strip, options)
+ code.extend([
+ *self._compile_directives(el, options, level + 1),
+ *self._flush_text(options, level + 1, rstrip=True),
+ ] or [indent_code('pass', level + 1)])
return code
- def _compile_directive_foreach(self, el, options, indent):
+ def _compile_directive_foreach(self, el, options, level):
"""Compile `t-foreach` expressions into a python code as a list of
strings.
@@ -1172,30 +1650,40 @@ class IrQWeb(models.AbstractModel):
expr_foreach = el.attrib.pop('t-foreach')
expr_as = el.attrib.pop('t-as')
- strip = self._stripText(options)
- if el.tag.lower() != 't':
- el.text = strip+(el.text or '')
+ if not expr_as:
+ raise KeyError('t-as')
- code = self._flushText(options, indent, True)
- content_foreach = self._compile_directives(el, options, indent + 1) + self._flushText(options, indent + 1, True)
+ if not VARNAME_REGEXP.match(expr_as):
+ raise ValueError(f'The varname {expr_as!r} can only contain alphanumeric characters and underscores.')
+
+
+ strip = self._rstrip_text(options)
+ code = self._flush_text(options, level)
+
+ content_foreach = []
+ if strip and el.tag.lower() != 't':
+ self._append_text(strip, options)
+ content_foreach.extend(
+ self._compile_directives(el, options, level + 1) +
+ self._flush_text(options, level + 1, rstrip=True))
t_foreach = self._make_name('t_foreach')
size = self._make_name('size')
has_value = self._make_name('has_value')
if expr_foreach.isdigit():
- code.append(self._indent(dedent(f"""
- values[{repr(expr_as + '_size')}] = {size} = {int(expr_foreach)}
+ code.append(indent_code(f"""
+ values[{expr_as + '_size'!r}] = {size} = {int(expr_foreach)}
{t_foreach} = range({size})
{has_value} = False
- """).strip(), indent))
+ """, level))
else:
- code.append(self._indent(dedent(f"""
+ code.append(indent_code(f"""
{t_foreach} = {self._compile_expr(expr_foreach)} or []
if isinstance({t_foreach}, Sized):
- values[{repr(expr_as + '_size')}] = {size} = len({t_foreach})
+ values[{expr_as + '_size'!r}] = {size} = len({t_foreach})
elif ({t_foreach}).__class__ == int:
- values[{repr(expr_as + '_size')}] = {size} = {t_foreach}
+ values[{expr_as + '_size'!r}] = {size} = {t_foreach}
{t_foreach} = range({size})
else:
{size} = None
@@ -1203,91 +1691,171 @@ class IrQWeb(models.AbstractModel):
if isinstance({t_foreach}, Mapping):
{t_foreach} = {t_foreach}.items()
{has_value} = True
- """).strip(), indent))
+ """, level))
- code.append(self._indent(dedent(f"""
+ code.append(indent_code(f"""
for index, item in enumerate({t_foreach}):
- values[{repr(expr_as + '_index')}] = index
+ values[{expr_as + '_index'!r}] = index
if {has_value}:
- values[{repr(expr_as)}], values[{repr(expr_as + '_value')}] = item
+ values[{expr_as!r}], values[{expr_as + '_value'!r}] = item
else:
- values[{repr(expr_as)}] = values[{repr(expr_as + '_value')}] = item
- values[{repr(expr_as + '_first')}] = values[{repr(expr_as + '_index')}] == 0
+ values[{expr_as!r}] = values[{expr_as + '_value'!r}] = item
+ values[{expr_as + '_first'!r}] = values[{expr_as + '_index'!r}] == 0
if {size} is not None:
- values[{repr(expr_as + '_last')}] = index + 1 == {size}
- values[{repr(expr_as + '_odd')}] = index % 2
- values[{repr(expr_as + '_even')}] = not values[{repr(expr_as + '_odd')}]
- values[{repr(expr_as + '_parity')}] = 'odd' if values[{repr(expr_as + '_odd')}] else 'even'
- """), indent))
+ values[{expr_as + '_last'!r}] = index + 1 == {size}
+ values[{expr_as + '_odd'!r}] = index % 2
+ values[{expr_as + '_even'!r}] = not values[{expr_as + '_odd'!r}]
+ values[{expr_as + '_parity'!r}] = 'odd' if values[{expr_as + '_odd'!r}] else 'even'
+ """, level))
- code.append(self._indent(f'log["last_path_node"] = {repr(options["root"].getpath(el))} ', indent + 1))
- code.extend(content_foreach or self._indent('continue', indent + 1))
+ code.append(indent_code(f'log["last_path_node"] = {options["root"].getpath(el)!r} ', level + 1))
+ code.extend(content_foreach or indent_code('continue', level + 1))
return code
- def _compile_directive_as(self, el, options, indent):
+ def _compile_directive_as(self, el, options, level):
"""Compile `t-as` expressions into a python code as a list of strings.
This method only check if this attributes is on the same node of a
`t-foreach` attribute.
"""
- if not options.get('t-foreach') is None:
- raise ValueError("t-as must be on the same node of t-foreach")
+ if 't-foreach' not in el.attrib:
+ raise SyntaxError("t-as must be on the same node of t-foreach")
return []
- def _compile_directive_out(self, el, options, indent):
+ def _compile_directive_out(self, el, options, level):
"""Compile `t-out` expressions into a python code as a list of
strings.
- The output can have some rendering option with `t-options-widget` or
- `t-options={'widget': ...}. The compiled code will call ``_get_widget``
- method at rendering time.
+
The code will contain evalution and rendering of the compiled value. If
the compiled value is None or False, the tag is not added to the render
- (Except if the widget forces rendering or there is default content.).
+ (Except if the widget forces rendering or there is default content).
+ (eg: `Default content if falsy`)
+
+ The output can have some rendering option with `t-options-widget` or
+ `t-options={'widget': ...}. At rendering time, The compiled code will
+ call ``_get_widget`` method or ``_get_field`` method for `t-field`.
+
+ A `t-field` will necessarily be linked to the value of a record field
+ (eg: ``), a t-out` can be applied
+ to any value (eg: ``).
"""
ttype = 't-out'
expr = el.attrib.pop('t-out', None)
if expr is None:
- # deprecated use.
- ttype = 't-esc'
- expr = el.attrib.pop('t-esc', None)
+ ttype = 't-field'
+ expr = el.attrib.pop('t-field', None)
if expr is None:
- ttype = 't-raw'
- expr = el.attrib.pop('t-raw')
+ # deprecated use.
+ ttype = 't-esc'
+ expr = el.attrib.pop('t-esc', None)
+ if expr is None:
+ ttype = 't-raw'
+ expr = el.attrib.pop('t-raw')
- code = self._flushText(options, indent)
- options['t_options_varname'] = 't_out_t_options'
- code_options = self._compile_directive(el, options, 'options', indent)
- code.extend(code_options)
+ code = self._flush_text(options, level)
- if expr == "0":
- if code_options:
- code.append(self._indent("content = Markup(''.join(values.get('0', [])))", indent))
+ code_options = el.attrib.pop('t-consumed-options', 'None')
+ tag_open = (
+ self._compile_directive(el, options, 'tag-open', level + 1) +
+ self._flush_text(options, level + 1))
+ tag_close = (
+ self._compile_directive(el, options, 'tag-close', level + 1) +
+ self._flush_text(options, level + 1))
+ default_body = (
+ self._compile_directive(el, options, 'inner-content', level + 1) +
+ self._flush_text(options, level + 1))
+
+ # The generated code will set the values of the content, attrs (used to
+ # output attributes) and the force_display (if the widget or field
+ # mark force_display as True, the tag will be inserted in the output
+ # even the value of content is None and without default value)
+
+ if expr == T_CALL_SLOT and code_options != 'True':
+ code.append(indent_code("if True:", level))
+ code.extend(tag_open)
+ code.append(indent_code("yield from values.get('0', [])", level + 1))
+ code.extend(tag_close)
+ return code
+ elif ttype == 't-field':
+ record, field_name = expr.rsplit('.', 1)
+ code.append(indent_code(f"""
+ field_attrs, content, force_display = self._get_field({self._compile_expr(record, raise_on_missing=True)}, {field_name!r}, {expr!r}, {el.tag!r}, values.pop('__qweb_options__', {{}}), compile_options, values)
+ if values.get('__qweb_attrs__') is None:
+ values['__qweb_attrs__'] = field_attrs
+ else:
+ values['__qweb_attrs__'].update(field_attrs)
+ content = self._compile_to_str(content)
+ """, level))
+
+ code.append(indent_code("content = self._compile_to_str(content)", level))
+ force_display_dependent = True
+ else:
+ if expr == T_CALL_SLOT:
+ code.append(indent_code("content = Markup(''.join(values.get('0', [])))", level))
else:
- code.extend(self._compile_tag_open(el, options, indent))
- code.extend(self._flushText(options, indent))
- code.append(self._indent("yield from values.get('0', [])", indent))
- code.extend(self._compile_tag_close(el, options))
- return code
- else:
- code.append(self._indent(f"content = {self._compile_expr(expr)}", indent))
+ code.append(indent_code(f"content = {self._compile_expr(expr)}", level))
- if code_options:
- code.append(self._indent(f"attrs, content, force_display = self._get_widget(content, {repr(expr)}, {repr(el.tag)}, t_out_t_options, compile_options, values)", indent))
- else:
- code.append(self._indent("force_display = None", indent))
+ if code_options == 'True':
+ code.append(indent_code(f"""
+ widget_attrs, content, force_display = self._get_widget(content, {expr!r}, {el.tag!r}, values.pop('__qweb_options__', {{}}), compile_options, values)
+ if values.get('__qweb_attrs__') is None:
+ values['__qweb_attrs__'] = widget_attrs
+ else:
+ values['__qweb_attrs__'].update(widget_attrs)
+ content = self._compile_to_str(content)
+ """, level))
+ force_display_dependent = True
+ else:
+ force_display_dependent = False
if ttype == 't-raw':
# deprecated use.
- code.append(self._indent(dedent("""
+ code.append(indent_code("""
if content is not None and content is not False:
content = Markup(content)
- """), indent))
+ """, level))
+
+ # The generated code will create the output tag with all attribute.
+ # If the value is not falsy or if there is default content or if it's
+ # in force_display mode, the tag is add into the output.
+
+ el.attrib.pop('t-tag', None) # code generating the output is done here
+
+ # generate code to display the tag if the value is not Falsy
+
+ code.append(indent_code("if content is not None and content is not False:", level))
+ code.extend(tag_open)
+ # Use str to avoid the escaping of the other html content because the
+ # yield generator MarkupSafe values will be join into an string in
+ # `_render`.
+ code.append(indent_code("yield str(escape(content))", level + 1))
+ code.extend(tag_close)
+
+ # generate code to display the tag with default content if the value is
+ # Falsy
+
+ if default_body or options['_text_concat']:
+ _text_concat = list(options['_text_concat'])
+ options['_text_concat'].clear()
+ code.append(indent_code("else:", level))
+ code.extend(tag_open)
+ code.extend(default_body)
+ options['_text_concat'].extend(_text_concat)
+ code.extend(tag_close)
+ elif force_display_dependent:
+
+ # generate code to display the tag if it's the force_diplay mode.
+
+ if tag_open + tag_close:
+ code.append(indent_code("elif force_display:", level))
+ code.extend(tag_open + tag_close)
+
+ code.append(indent_code("""else: values.pop('__qweb_attrs__', None)""", level))
- code.extend(self._compile_widget_value(el, options, indent, without_attributes=not code_options))
return code
- def _compile_directive_esc(self, el, options, indent):
+ def _compile_directive_esc(self, el, options, level):
# deprecated use.
if options.get('dev_mode'):
_logger.warning(
@@ -1295,9 +1863,9 @@ class IrQWeb(models.AbstractModel):
el.get('t-esc'),
options.get('ref', ''),
)
- return self._compile_directive_out(el, options, indent)
+ return self._compile_directive_out(el, options, level)
- def _compile_directive_raw(self, el, options, indent):
+ def _compile_directive_raw(self, el, options, level):
# deprecated use.
_logger.warning(
"Found deprecated directive @t-raw=%r in template %r. Replace by "
@@ -1306,14 +1874,16 @@ class IrQWeb(models.AbstractModel):
el.get('t-raw'),
options.get('ref', ''),
)
- return self._compile_directive_out(el, options, indent)
+ return self._compile_directive_out(el, options, level)
- def _compile_directive_field(self, el, options, indent):
+ def _compile_directive_field(self, el, options, level):
"""Compile `t-field` expressions into a python code as a list of
strings.
+
The compiled code will call ``_get_field`` method at rendering time
using the type of value supplied by the field. This behavior can be
changed with `t-options-widget` or `t-options={'widget': ...}.
+
The code will contain evalution and rendering of the compiled value
value from the record field. If the compiled value is None or False,
the tag is not added to the render
@@ -1322,63 +1892,15 @@ class IrQWeb(models.AbstractModel):
tagName = el.tag
assert tagName not in ("table", "tbody", "thead", "tfoot", "tr", "td",
"li", "ul", "ol", "dl", "dt", "dd"),\
- "RTE widgets do not work correctly on %r elements" % tagName
+ "QWeb widgets do not work correctly on %r elements" % tagName
assert tagName != 't',\
"t-field can not be used on a t element, provide an actual HTML node"
assert "." in el.get('t-field'),\
"t-field must have at least a dot like 'record.field_name'"
- expression = el.attrib.pop('t-field')
- record, field_name = expression.rsplit('.', 1)
+ return self._compile_directive_out(el, options, level)
- code = []
- options['t_options_varname'] = 't_field_t_options'
- code_options = self._compile_directive(el, options, 'options', indent) or [self._indent("t_field_t_options = {}", indent)]
- code.extend(code_options)
- code.append(self._indent(f"attrs, content, force_display = self._get_field({self._compile_expr(record, raise_on_missing=True)}, {repr(field_name)}, {repr(expression)}, {repr(tagName)}, t_field_t_options, compile_options, values)", indent))
- code.append(self._indent("content = self._compile_to_str(content)", indent))
- code.extend(self._compile_widget_value(el, options, indent))
- return code
-
- def _compile_widget_value(self, el, options, indent=0, without_attributes=False):
- """Take care of part of the compilation of `t-out` and `t-field` (and
- the technical directive `t-tag). This is the part that takes care of
- whether or not created the tags and the default content of the element.
- """
- el.attrib.pop('t-tag', None)
-
- code = self._flushText(options, indent)
- code.append(self._indent("if content is not None and content is not False:", indent))
- code.extend(self._compile_tag_open(el, options, indent + 1, not without_attributes))
- code.extend(self._flushText(options, indent + 1))
- # Use str to avoid the escaping of the other html content.
- code.append(self._indent("yield str(escape(content))", indent + 1))
- code.extend(self._compile_tag_close(el, options))
- code.extend(self._flushText(options, indent + 1))
-
- default_body = self._compile_directive_inner_content(el, options, indent + 1)
- if default_body or options['_text_concat']:
- # default content
- _text_concat = list(options['_text_concat'])
- options['_text_concat'].clear()
- code.append(self._indent("else:", indent))
- code.extend(self._compile_tag_open(el, options, indent + 1, not without_attributes))
- code.extend(self._flushText(options, indent + 1))
- code.extend(default_body)
- options['_text_concat'].extend(_text_concat)
- code.extend(self._compile_tag_close(el, options))
- code.extend(self._flushText(options, indent + 1))
- else:
- content = (self._compile_tag_open(el, options, indent + 1, not without_attributes) +
- self._compile_tag_close(el, options) +
- self._flushText(options, indent + 2))
- if content:
- code.append(self._indent("elif force_display:", indent))
- code.extend(content)
-
- return code
-
- def _compile_directive_call(self, el, options, indent):
+ def _compile_directive_call(self, el, options, level):
"""Compile `t-call` expressions into a python code as a list of
strings.
@@ -1399,89 +1921,97 @@ class IrQWeb(models.AbstractModel):
nsmap = options.get('nsmap')
- code = self._flushText(options, indent, True)
- options['t_options_varname'] = 't_call_t_options'
- code_options = self._compile_directive(el, options, 'options', indent) or [self._indent("t_call_t_options = {}", indent)]
- code.extend(code_options)
-
- # content (t-out="0" and variables)
- def_name = "t_call_content"
- content = self._compile_directive_inner_content(el, options, indent + 1)
- if content and not options['_text_concat']:
- self._appendText('', options) # To ensure the template function is a generator and doesn't become a regular function
- content.extend(self._flushText(options, indent + 1))
- if content:
- code.append(self._indent(f"def {def_name}(self, values, log):", indent))
- code.extend(content)
- code.append(self._indent("t_call_values = values.copy()", indent))
- code.append(self._indent(f"t_call_values['0'] = Markup(''.join({def_name}(self, t_call_values, log)))", indent))
- else:
- code.append(self._indent("t_call_values = values.copy()", indent))
- code.append(self._indent("t_call_values['0'] = Markup()", indent))
+ code = self._flush_text(options, level, rstrip=el.tag.lower() == 't')
# options
- code.append(self._indent(dedent(f"""
+ code.append(indent_code(f"""
+ t_options = values.pop('__qweb_options__', {{}})
t_call_options = compile_options.copy()
- t_call_options.update({{'caller_template': {repr(str(options.get('template')))}, 'last_path_node': {repr(str(options['root'].getpath(el)))} }})
- """).strip(), indent))
+ t_call_options.update({{'caller_template': {str(options.get('template'))!r}, 'last_path_node': {str(options['root'].getpath(el))!r} }})
+ """, level))
if nsmap:
# update this dict with the current nsmap so that the callee know
# if he outputting the xmlns attributes is relevenat or not
nsmap = []
for key, value in options['nsmap'].items():
if isinstance(key, str):
- nsmap.append(f'{repr(key)}:{repr(value)}')
+ nsmap.append(f'{key!r}:{value!r}')
else:
- nsmap.append(f'None:{repr(value)}')
- code.append(self._indent(f"t_call_options.update(nsmap={{{', '.join(nsmap)}}})", indent))
+ nsmap.append(f'None:{value!r}')
+ code.append(indent_code(f"t_call_options.update(nsmap={{{', '.join(nsmap)}}})", level))
+
+ # values (t-out="0" from content and variables from t-set and t-set-*)
+ def_name = self._make_name("t_call_values")
+ code.append(indent_code(f"def {def_name}(self, compile_options, values, log):", level))
+ code.extend(self._compile_directive(el, options, 'inner-content', level + 1))
+ code.extend(self._compile_directive(el, options, 'set', level + 1))
+ self._append_text('', options) # To ensure the template function is a generator and doesn't become a regular function
+ code.extend(self._flush_text(options, level + 1, rstrip=True))
+ code.append(indent_code("t_call_values = values.copy()", level))
+ code.append(indent_code(f"t_call_values['0'] = Markup(''.join({def_name}(self, compile_options, t_call_values, log)))", level))
template = self._compile_format(expr)
# call
- if code_options:
- code.append(self._indent("t_call_options.update(t_call_t_options)", indent))
- code.append(self._indent(dedent(f"""
+ if el.attrib.pop('t-consumed-options', 'None') == 'True':
+ code.append(indent_code("t_call_options.update(t_options)", level))
+ code.append(indent_code(f"""
if compile_options.get('lang') != t_call_options.get('lang'):
self_lang = self.with_context(lang=t_call_options.get('lang'))
yield from self_lang._compile({template}, t_call_options)(self_lang, t_call_values)
else:
yield from self._compile({template}, t_call_options)(self, t_call_values)
- """).strip(), indent))
+ """, level))
else:
- code.append(self._indent(f"yield from self._compile({template}, t_call_options)(self, t_call_values)", indent))
+ code.append(indent_code(f"yield from self._compile({template}, t_call_options)(self, t_call_values)", level))
return code
- def _compile_directive_lang(self, el, options, indent):
+ def _compile_directive_lang(self, el, options, level):
+ if 't-call' not in el.attrib:
+ raise SyntaxError("t-lang is an alias of t-options-lang but only available on the same node of t-call")
el.attrib['t-options-lang'] = el.attrib.pop('t-lang')
- return self._compile_node(el, options, indent)
+ return self._compile_node(el, options, level)
- def _compile_directive_call_assets(self, el, options, indent):
- """ This special 't-call' tag can be used in order to aggregate/minify javascript and css assets"""
- if len(el):
+ def _compile_directive_call_assets(self, el, options, level):
+ """ This special 't-call-assets' tag can be used in order to aggregate/minify javascript and css assets"""
+ if len(el) > 0:
raise SyntaxError("t-call-assets cannot contain children nodes")
- code = self._flushText(options, indent)
- code.append(self._indent("""t_call_assets_nodes = self._get_asset_nodes(%(xmlid)s, css=%(css)s, js=%(js)s, debug=values.get("debug"), async_load=%(async_load)s, defer_load=%(defer_load)s, lazy_load=%(lazy_load)s, media=%(media)s)""".strip() % {
- 'xmlid': repr(el.get('t-call-assets')),
- 'css': self._compile_bool(el.get('t-css', True)),
- 'js': self._compile_bool(el.get('t-js', True)),
- 'async_load': self._compile_bool(el.get('async_load', False)),
- 'defer_load': self._compile_bool(el.get('defer_load', False)),
- 'lazy_load': self._compile_bool(el.get('lazy_load', False)),
- 'media': repr(el.get('media')) if el.get('media') else False,
- }, indent))
+ code = self._flush_text(options, level)
+ xmlid = el.attrib.pop('t-call-assets')
+ css = self._compile_bool(el.attrib.pop('t-css', True))
+ js = self._compile_bool(el.attrib.pop('t-js', True))
+ async_load = self._compile_bool(el.attrib.pop('async_load', False))
+ defer_load = self._compile_bool(el.attrib.pop('defer_load', False))
+ lazy_load = self._compile_bool(el.attrib.pop('lazy_load', False))
+ media = el.attrib.pop('media', False)
+ code.append(indent_code(f"""
+ t_call_assets_nodes = self._get_asset_nodes(
+ {xmlid!r},
+ css={css},
+ js={js},
+ debug=values.get("debug"),
+ async_load={async_load},
+ defer_load={defer_load},
+ lazy_load={lazy_load},
+ media={media!r},
+ )
+ """.strip(), level))
- code.append(self._indent(dedent("""
- for index, (tagName, attrs, content) in enumerate(t_call_assets_nodes):
+ code.append(indent_code("""
+ for index, (tagName, asset_attrs, content) in enumerate(t_call_assets_nodes):
if index:
yield '\\n '
yield '<'
yield tagName
- """), indent))
- code.extend(self._compile_attributes(options, indent + 1))
- code.append(self._indent(dedent("""
- if not content and tagName in self._void_elements:
+
+ attrs = self._post_processing_att(tagName, asset_attrs, compile_options)
+ for name, value in attrs.items():
+ if value or isinstance(value, str):
+ yield f' {escape(str(name))}="{escape(str(value))}"'
+
+ if not content and tagName in VOID_ELEMENTS:
yield '/>'
else:
yield '>'
@@ -1490,7 +2020,7 @@ class IrQWeb(models.AbstractModel):
yield ''
yield tagName
yield '>'
- """).strip(), indent + 1))
+ """, level))
return code
@@ -1608,7 +2138,7 @@ class IrQWeb(models.AbstractModel):
if not is_js and not is_css:
continue
- mimetype = 'text/javascript' if is_js else 'text/%s' % ext
+ mimetype = 'text/javascript' if is_js else f'text/{ext}'
if can_aggregate(path):
segments = [segment for segment in path.split('/') if segment]
files.append({
@@ -1643,11 +2173,7 @@ class IrQWeb(models.AbstractModel):
return AssetsBundle(bundle_name, files, env=env, css=css, js=js)
def _generate_asset_nodes(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
- nodeAttrs = None
- if css and media:
- nodeAttrs = {
- 'media': media,
- }
+ nodeAttrs = {'media': media} if css and media else None
files, remains = self._get_asset_content(bundle, nodeAttrs, defer_load=defer_load, lazy_load=lazy_load)
asset = self._get_asset_bundle(bundle, files, env=self.env, css=css, js=js)
remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] == 'script')]
diff --git a/odoo/addons/base/tests/test_profiler.py b/odoo/addons/base/tests/test_profiler.py
index b257419d8c4..e75bcad0fd3 100644
--- a/odoo/addons/base/tests/test_profiler.py
+++ b/odoo/addons/base/tests/test_profiler.py
@@ -463,25 +463,25 @@ class TestProfiling(TransactionCase):
'type': 'qweb',
'arch_db': '''
- [: ]
+ [: ]
'''
})
child_template = self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
- 'arch_db': ' '
+ 'arch_db': ' '
})
self.env.cr.execute("INSERT INTO ir_model_data(name, model, res_id, module)"
"VALUES ('dummy', 'ir.ui.view', %s, 'base')", [child_template.id])
values = {'add_one_query': lambda: self.env.cr.execute('SELECT id FROM ir_ui_view LIMIT 1') or 'query'}
result = u"""
- [0: a query 3]
+ [0: a query 3]
query
- [1: b query 2]
+ [1: b query 2]
query
- [2: c query 1]
+ [2: c query 1]
query
"""
@@ -510,34 +510,41 @@ class TestProfiling(TransactionCase):
for data in p.collectors[0].entries[0]['results']['data']:
data.pop('delay')
+ data = p.collectors[0].entries[0]['results']['data']
expected = [
# pylint: disable=bad-whitespace
# first template and first directive
{'view_id': template.id, 'xpath': '/t/t', 'directive': """t-foreach="{'a': 3, 'b': 2, 'c': 1}" t-as='item'""", 'query': 0},
# first pass in the loop
{'view_id': template.id, 'xpath': '/t/t/t[1]', 'directive': "t-esc='item_index'", 'query': 0},
- {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-call='base.dummy'", 'query': 0}, # the compiled template method is in cache
+ {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-call='base.dummy'", 'query': 0}, # 0 because the template is in ir.ui.view cache
+ {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-set-record='item'", 'query': 0}, # t-set it's consumed by t-call
# first pass in the loop: content of the child template
- {'view_id': child_template.id, 'xpath': '/t/span/t[1]', 'directive': "t-esc='item'", 'query': 0},
+ {'view_id': child_template.id, 'xpath': '/t/span', 'directive': "t-attf-class='myclass'", 'query': 0},
+ {'view_id': child_template.id, 'xpath': '/t/span/t[1]', 'directive': "t-esc='record'", 'query': 0},
{'view_id': child_template.id, 'xpath': '/t/span/t[2]', 'directive': "t-esc='add_one_query()'", 'query': 1},
{'view_id': template.id, 'xpath': '/t/t/t[3]', 'directive': "t-esc='item_value'", 'query': 0},
{'view_id': template.id, 'xpath': '/t/t/b', 'directive': "t-esc='add_one_query()'", 'query':1},
# second pass in the loop
{'view_id': template.id, 'xpath': '/t/t/t[1]', 'directive': "t-esc='item_index'", 'query': 0},
- {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-call='base.dummy'", 'query': 0}, # 0 because the template is in cache
- {'view_id': child_template.id, 'xpath': '/t/span/t[1]', 'directive': "t-esc='item'", 'query': 0},
+ {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-call='base.dummy'", 'query': 0},
+ {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-set-record='item'", 'query': 0},
+ {'view_id': child_template.id, 'xpath': '/t/span', 'directive': "t-attf-class='myclass'", 'query': 0},
+ {'view_id': child_template.id, 'xpath': '/t/span/t[1]', 'directive': "t-esc='record'", 'query': 0},
{'view_id': child_template.id, 'xpath': '/t/span/t[2]', 'directive': "t-esc='add_one_query()'", 'query': 1},
{'view_id': template.id, 'xpath': '/t/t/t[3]', 'directive': "t-esc='item_value'", 'query': 0},
{'view_id': template.id, 'xpath': '/t/t/b', 'directive': "t-esc='add_one_query()'", 'query':1},
# third pass in the loop
{'view_id': template.id, 'xpath': '/t/t/t[1]', 'directive': "t-esc='item_index'", 'query': 0},
{'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-call='base.dummy'", 'query': 0},
- {'view_id': child_template.id, 'xpath': '/t/span/t[1]', 'directive': "t-esc='item'", 'query': 0},
+ {'view_id': template.id, 'xpath': '/t/t/t[2]', 'directive': "t-set-record='item'", 'query': 0},
+ {'view_id': child_template.id, 'xpath': '/t/span', 'directive': "t-attf-class='myclass'", 'query': 0},
+ {'view_id': child_template.id, 'xpath': '/t/span/t[1]', 'directive': "t-esc='record'", 'query': 0},
{'view_id': child_template.id, 'xpath': '/t/span/t[2]', 'directive': "t-esc='add_one_query()'", 'query': 1},
{'view_id': template.id, 'xpath': '/t/t/t[3]', 'directive': "t-esc='item_value'", 'query': 0},
{'view_id': template.id, 'xpath': '/t/t/b', 'directive': "t-esc='add_one_query()'", 'query':1},
]
- self.assertEqual(p.collectors[0].entries[0]['results']['data'], expected)
+ self.assertEqual(data, expected)
def test_default_recorders(self):
with Profiler(db=None) as p:
diff --git a/odoo/addons/base/tests/test_qweb.py b/odoo/addons/base/tests/test_qweb.py
index df10d269ca6..11957e67ca9 100644
--- a/odoo/addons/base/tests/test_qweb.py
+++ b/odoo/addons/base/tests/test_qweb.py
@@ -9,10 +9,12 @@ import markupsafe
from lxml import etree, html
from lxml.builder import E
+from copy import deepcopy
+from textwrap import dedent
from odoo.modules import get_module_resource
from odoo.tests.common import TransactionCase
-from odoo.addons.base.models.ir_qweb import QWebException
+from odoo.addons.base.models.ir_qweb import QWebException, render
from odoo.tools import misc, mute_logger
from odoo.tools.json import scriptsafe as json_scriptsafe
@@ -26,46 +28,46 @@ class TestQWebTField(TransactionCase):
self.engine = self.env_branding['ir.qweb']
def test_trivial(self):
- field = etree.Element('span', {'t-field': u'company.name'})
+ field = etree.Element('span', {'t-field': 'company.name'})
company = self.env['res.company'].create({'name': "My Test Company"})
result = self.engine._render(field, {'company': company})
self.assertEqual(
etree.fromstring(result),
- etree.fromstring(u'%s' % (
+ etree.fromstring('%s' % (
company.id,
- u"My Test Company",
+ "My Test Company",
)),
)
def test_i18n(self):
- field = etree.Element('span', {'t-field': u'company.name'})
- s = u"Testing «ταБЬℓσ»: 1<2 & 4+1>3, now 20% off!"
+ field = etree.Element('span', {'t-field': 'company.name'})
+ s = "Testing «ταБЬℓσ»: 1<2 & 4+1>3, now 20% off!"
company = self.env['res.company'].create({'name': s})
result = self.engine._render(field, {'company': company})
self.assertEqual(
etree.fromstring(result),
- etree.fromstring(u'%s' % (
+ etree.fromstring('%s' % (
company.id,
misc.html_escape(s),
)),
)
def test_reject_crummy_tags(self):
- field = etree.Element('td', {'t-field': u'company.name'})
+ field = etree.Element('td', {'t-field': 'company.name'})
- with self.assertRaisesRegex(QWebException, r'^RTE widgets do not work correctly'):
+ with self.assertRaisesRegex(QWebException, r'QWeb widgets do not work correctly'):
self.engine._render(field, {'company': None})
def test_reject_t_tag(self):
- field = etree.Element('t', {'t-field': u'company.name'})
+ field = etree.Element('t', {'t-field': 'company.name'})
- with self.assertRaisesRegex(QWebException, r'^t-field can not be used on a t element'):
+ with self.assertRaisesRegex(QWebException, r't-field can not be used on a t element'):
self.engine._render(field, {'company': None})
def test_render_t_options(self):
@@ -77,7 +79,7 @@ class TestQWebTField(TransactionCase):
"""
})
text = etree.fromstring(view1._render()).find('span').text
- self.assertEqual(text, u'5.0000')
+ self.assertEqual(text, '5.0000')
def test_xss_breakout(self):
view = self.env['ir.ui.view'].create({
@@ -183,7 +185,7 @@ class TestQWebNS(TransactionCase):
"""
})
- expected_result = etree.fromstring(u"""
+ expected_result = etree.fromstring("""
@@ -197,14 +199,14 @@ class TestQWebNS(TransactionCase):
self.assertEqual(etree.fromstring(view1._render()), expected_result)
def test_render_static_xml_with_namespace_3(self):
- expected_result = u"""
+ expected_result = """
"""
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
%s
""" % expected_result
})
@@ -214,7 +216,7 @@ class TestQWebNS(TransactionCase):
def test_render_static_xml_with_namespace_dynamic(self):
""" Test the rendering on a namespaced view with dynamic URI (need default namespace uri).
"""
- tempate = u"""
+ tempate = """
@@ -224,7 +226,7 @@ class TestQWebNS(TransactionCase):
"""
- expected_result = u"""
+ expected_result = """
@@ -240,7 +242,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
%s
""" % tempate
})
@@ -253,7 +255,7 @@ class TestQWebNS(TransactionCase):
""" Test the rendering on a namespaced view with dynamic URI (need default namespace uri).
Default URIs must be differents.
"""
- tempate = u"""
+ tempate = """
@@ -266,7 +268,7 @@ class TestQWebNS(TransactionCase):
"""
- expected_result = u"""
+ expected_result = """
@@ -285,7 +287,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
%s
""" % tempate
})
@@ -301,14 +303,14 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
"""
})
- expected_result = etree.fromstring(u"""test""")
+ expected_result = etree.fromstring("""test""")
self.assertEqual(etree.fromstring(view1._render()), expected_result)
@@ -319,7 +321,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
blabla
@@ -328,7 +330,7 @@ class TestQWebNS(TransactionCase):
"""
})
- expected_result = etree.fromstring(u"""
+ expected_result = etree.fromstring("""
blabla
@@ -343,7 +345,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -360,7 +362,7 @@ class TestQWebNS(TransactionCase):
"""
})
- expected_result = etree.fromstring(u"""
+ expected_result = etree.fromstring("""
@@ -383,7 +385,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -401,7 +403,7 @@ class TestQWebNS(TransactionCase):
"""
})
- expected_result = etree.fromstring(u"""
+ expected_result = etree.fromstring("""
@@ -422,7 +424,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -434,7 +436,7 @@ class TestQWebNS(TransactionCase):
"""
})
- expected_result = etree.fromstring(u"""
+ expected_result = etree.fromstring("""
1.0
@@ -455,14 +457,14 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
abc
"""
})
- expected_result = etree.fromstring(u"""abc""")
+ expected_result = etree.fromstring("""abc""")
self.assertEqual(etree.fromstring(view1._render()), expected_result)
@@ -470,7 +472,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -485,7 +487,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -504,7 +506,7 @@ class TestQWebNS(TransactionCase):
view2 = self.env['ir.ui.view'].create({
'name': "dummy2",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -539,7 +541,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -557,14 +559,14 @@ class TestQWebNS(TransactionCase):
'name': "dummy_ext",
'type': 'qweb',
'inherit_id': view1.id,
- 'arch': u"""
+ 'arch': """
Oranges
"""
})
- expected_result = etree.fromstring(u"""
+ expected_result = etree.fromstring("""
@@ -588,7 +590,7 @@ class TestQWebNS(TransactionCase):
view1 = self.env['ir.ui.view'].create({
'name': "dummy",
'type': 'qweb',
- 'arch': u"""
+ 'arch': """
@@ -605,69 +607,6 @@ class TestQWebNS(TransactionCase):
with self.assertRaises(QWebException, msg=error_msg):
view1._render()
- def test_render_t_call_propagates_t_lang(self):
- current_lang = 'en_US'
- other_lang = 'fr_FR'
-
- lang = self.env['res.lang']._activate_lang(other_lang)
- lang.write({
- 'decimal_point': '*',
- 'thousands_sep': '/'
- })
-
- view1 = self.env['ir.ui.view'].create({
- 'name': "callee",
- 'type': 'qweb',
- 'arch': u"""
-
-
-
- """
- })
- self.env['ir.model.data'].create({
- 'name': 'callee',
- 'model': 'ir.ui.view',
- 'module': 'base',
- 'res_id': view1.id,
- })
-
- view2 = self.env['ir.ui.view'].create({
- 'name': "calling",
- 'type': 'qweb',
- 'arch': u"""
-
-
-
- """ % other_lang
- })
-
- rendered = view2.with_context(lang=current_lang)._render().strip()
- self.assertEqual(rendered, '9/000/000*00')
-
- def test_render_barcode(self):
- partner = self.env['res.partner'].create({
- 'name': 'bacode_test',
- 'barcode': 'test'
- })
-
- view = self.env['ir.ui.view'].create({
- 'name': "a_barcode_view",
- 'type': 'qweb',
- })
-
- view.arch = u""""""
- rendered = view._render(values={'partner': partner}).strip()
- self.assertRegex(rendered, r'
')
-
- partner.barcode = '4012345678901'
- view.arch = u""""""
- ean_rendered = view._render(values={'partner': partner}).strip()
- self.assertRegex(ean_rendered, r'
')
-
- view.arch = u""""""
- auto_rendered = view._render(values={'partner': partner}).strip()
- self.assertRegex(auto_rendered, r'
')
-
class TestQWebBasic(TransactionCase):
def test_compile_expr(self):
tests = [
@@ -707,7 +646,7 @@ class TestQWebBasic(TransactionCase):
expr_namespace = IrQweb._compile_expr(expr)
compiled = compile("""def test(values):\n values['result'] = %s""" % expr_namespace, '', 'exec')
- globals_dict = IrQweb._prepare_globals({}, {})
+ globals_dict = IrQweb._prepare_globals()
values = {}
unsafe_eval(compiled, globals_dict, values)
test = values['test']
@@ -716,6 +655,66 @@ class TestQWebBasic(TransactionCase):
q_result = dict(q_values, result=result)
self.assertDictEqual(q_values, q_result, "Should compile: %s" % expr)
+ def test_foreach_as_error_1(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+ [: ]
+ '''
+ })
+
+ with self.assertRaises(QWebException):
+ t._render()
+
+ try:
+ t._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn("KeyError: 't-as'", error)
+ self.assertIn('', error)
+
+ def test_foreach_as_error_2(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+ [: ]
+ '''
+ })
+
+ with self.assertRaises(QWebException):
+ t._render()
+
+ try:
+ t._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn("KeyError: 't-as'", error)
+ self.assertIn('', error)
+
+ def test_foreach_as_error_3(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+ [: ]
+ '''
+ })
+
+ with self.assertRaises(QWebException):
+ t._render()
+
+ try:
+ t._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn("The varname 'b-2' can only contain alphanumeric characters and underscores", error)
+ self.assertIn('', error)
+
def test_compile_expr_security(self):
t = self.env['ir.ui.view'].create({
'name': 'test',
@@ -740,7 +739,7 @@ class TestQWebBasic(TransactionCase):
[: ]
'''
})
- result = u"""
+ result = """
[0: 3 3]
[1: 2 2]
[2: 1 1]
@@ -758,7 +757,7 @@ class TestQWebBasic(TransactionCase):
[: ]
'''
})
- result = u"""
+ result = """
[0: a 3]
[1: b 2]
[2: c 1]
@@ -849,11 +848,87 @@ class TestQWebBasic(TransactionCase):
rendered = self.env['ir.qweb']._render(t.id, values)
self.assertEqual(rendered.strip(), result.strip())
+ def test_set_1(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+ '''
+ })
+ result = """
+ abc 1
+ """
+ rendered = self.env['ir.qweb']._render(t.id)
+ self.assertEqual(rendered.strip(), result.strip())
+
+ def test_set_2(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+ '''
+ })
+ result = """
+ abc 1
+ """
+ rendered = self.env['ir.qweb']._render(t.id)
+ self.assertEqual(rendered.strip(), result.strip())
+
+ def test_set_3(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+ '''
+ })
+ result = """
+ abc 1
+ """
+ rendered = self.env['ir.qweb']._render(t.id)
+ self.assertEqual(rendered.strip(), result.strip())
+
+ def test_set_4(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+ '''
+ })
+ result = """
+ abc 1
+ """
+ rendered = self.env['ir.qweb']._render(t.id)
+ self.assertEqual(rendered.strip(), result.strip())
+
+ def test_set_5(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+ '''
+ })
+ result = """
+ abc 1
+ """
+ rendered = self.env['ir.qweb']._render(t.id)
+ self.assertEqual(rendered.strip(), result.strip())
+
def test_set_body_1(self):
t = self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
- 'arch_db': '''
+ 'arch_db': '''
[%s]
123
'''
@@ -869,7 +944,7 @@ class TestQWebBasic(TransactionCase):
t = self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
- 'arch_db': '''
+ 'arch_db': '''
toto
123
@@ -884,6 +959,44 @@ class TestQWebBasic(TransactionCase):
rendered = self.env['ir.qweb']._render(t.id)
self.assertEqual(rendered.strip(), result.strip())
+ def test_set_error_1(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+ '''
+ })
+
+ with self.assertRaises(QWebException):
+ t._render()
+
+ try:
+ t._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn("KeyError: 't-set'", error)
+ self.assertIn('', error)
+
+ def test_set_error_2(self):
+ t = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+ '''
+ })
+
+ with self.assertRaises(QWebException):
+ t._render()
+
+ try:
+ t._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn("The varname can only contain alphanumeric characters and underscores", error)
+ self.assertIn('', error)
+
def test_out(self):
t = self.env['ir.ui.view'].create({
'name': 'test',
@@ -903,7 +1016,7 @@ class TestQWebBasic(TransactionCase):
'''
})
- result = u"""
+ result = """
Powered by 1-2
"""
rendered = self.env['ir.qweb']._render(t.id, {'a': 1, 'b': 2})
@@ -913,7 +1026,7 @@ class TestQWebBasic(TransactionCase):
t = self.env['ir.ui.view'].create({
'name': 'test',
'type': 'qweb',
- 'arch_db': '''
+ 'arch_db': '''
Toto %s
[%s , %s]
123
@@ -1102,7 +1215,8 @@ class TestQWebBasic(TransactionCase):
try:
self.env['ir.qweb']._render(t.id)
except QWebException as e:
- self.assertIn('', e.message)
+ error = str(e)
+ self.assertIn('', error)
def test_error_message_2(self):
t = self.env['ir.ui.view'].create({
@@ -1122,10 +1236,365 @@ class TestQWebBasic(TransactionCase):
try:
self.env['ir.qweb']._render(t.id)
except QWebException as e:
- self.assertIn('Cannot compile expression', e.message)
- self.assertIn('', e.message)
+ error = str(e)
+ self.assertIn('Can not compile expression', error)
+ self.assertIn('', error)
+
+ def test_call_set(self):
+ view0 = self.env['ir.ui.view'].create({
+ 'name': "dummy",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+ """
+ })
+ self.env.cr.execute("INSERT INTO ir_model_data(name, model, res_id, module)"
+ "VALUES ('dummy', 'ir.ui.view', %s, 'base')", [view0.id])
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "other",
+ 'type': 'qweb',
+ 'arch': """
+
+
+ 1
+ 1
+
+ 2
+
+
+
+
+
+ """
+ })
+
+ result = view1._render({})
+ self.assertEqual(etree.fromstring(result), etree.fromstring("""
+
+ """), 'render t-call use lexical scoping, t-call content use independant scoping')
+
+ def test_call_arg(self):
+ view0 = self.env['ir.ui.view'].create({
+ 'name': "dummy",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+ """
+ })
+ self.env.cr.execute("INSERT INTO ir_model_data(name, model, res_id, module)"
+ "VALUES ('dummy', 'ir.ui.view', %s, 'base')", [view0.id])
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "other",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+
+
+ """
+ })
+
+ result = view1._render({})
+ self.assertEqual(etree.fromstring(result), etree.fromstring("""
+
+ """), 'render t-call with t-set attributes')
+
+ def test_call_arg_error(self):
+ view0 = self.env['ir.ui.view'].create({
+ 'name': "dummy",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+ """
+ })
+ self.env.cr.execute("INSERT INTO ir_model_data(name, model, res_id, module)"
+ "VALUES ('dummy', 'ir.ui.view', %s, 'base')", [view0.id])
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "other",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+
+
+ """
+ })
+
+ with self.assertRaises(QWebException):
+ view1._render()
+
+ try:
+ view1._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn("The varname 'a-2' can only contain alphanumeric characters and underscores", error)
+ self.assertIn('', error)
+
+ def test_call_error(self):
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "other",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+
+
+ """
+ })
+
+ with self.assertRaises(QWebException):
+ view1._render()
+
+ try:
+ view1._render()
+ except QWebException as e:
+ error = str(e)
+ self.assertIn('External ID not found in the system: base.dummy', error)
+ self.assertIn('', error)
+
+ def test_render_t_call_propagates_t_lang(self):
+ current_lang = 'en_US'
+ other_lang = 'fr_FR'
+
+ lang = self.env['res.lang']._activate_lang(other_lang)
+ lang.write({
+ 'decimal_point': '*',
+ 'thousands_sep': '/'
+ })
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "callee",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+ """
+ })
+ self.env['ir.model.data'].create({
+ 'name': 'callee',
+ 'model': 'ir.ui.view',
+ 'module': 'base',
+ 'res_id': view1.id,
+ })
+
+ view2 = self.env['ir.ui.view'].create({
+ 'name': "calling",
+ 'type': 'qweb',
+ 'arch': """
+
+
+
+ """ % other_lang
+ })
+
+ rendered = view2.with_context(lang=current_lang)._render().strip()
+ self.assertEqual(rendered, '9/000/000*00')
+
+ def test_render_barcode(self):
+ partner = self.env['res.partner'].create({
+ 'name': 'bacode_test',
+ 'barcode': 'test'
+ })
+
+ view = self.env['ir.ui.view'].create({
+ 'name': "a_barcode_view",
+ 'type': 'qweb',
+ })
+
+ view.arch = """"""
+ rendered = view._render(values={'partner': partner}).strip()
+ self.assertRegex(rendered, r'')
+
+ partner.barcode = '4012345678901'
+ view.arch = """"""
+ ean_rendered = view._render(values={'partner': partner}).strip()
+ self.assertRegex(ean_rendered, r'')
+
+ view.arch = """"""
+ auto_rendered = view._render(values={'partner': partner}).strip()
+ self.assertRegex(auto_rendered, r'')
+
+ def test_render_comment_tail(self):
+ """ Test the rendering of a tail text, near a comment.
+ """
+
+ view1 = self.env['ir.ui.view'].create({
+ 'name': "dummy",
+ 'type': "qweb",
+ 'arch': """
+
+
+
+ Text 1
+
+ Text 2
+ ok
+
+ """
+ })
+ emptyline = '\n '
+ expected = markupsafe.Markup('Text 1' + emptyline + emptyline + 'Text 2' + emptyline + 'ok')
+ self.assertEqual(view1._render().strip(), expected)
+
+ def test_void_element(self):
+ view = self.env['ir.ui.view'].create({
+ 'name': 'master',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+
+
+
+
+ '''
+ })
+
+ result = '''
+
+
+ 1
+ '''
+ rendered = self.env['ir.qweb']._render(view.id)
+
+ self.assertEqual(str(rendered).strip(), result.strip())
+
+ def test_space_remove_technical_space(self):
+ test = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+ '''
+ })
+ self.env.cr.execute("INSERT INTO ir_model_data(name, model, res_id, module)"
+ "VALUES ('test', 'ir.ui.view', %s, 'base')", [test.id])
+
+ view = self.env['ir.ui.view'].create({
+ 'name': 'master',
+ 'type': 'qweb',
+ 'arch_db': '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ a
+
+
+ b
+
+
+ c
+ d
+
+
+
+
+
+
+
+
+
+
+ a
+
+
+ a
+
+
+ b
+
+
+ c
+ d
+
+
+
+ '''
+ })
+
+ result = '''
+
+
+
+
+
+ 0
+ 1
+
+
+ b
+
+ c 1
+ d
+
+
+
+
+
+ 0
+
+
+ 1
+
+
+
+ b
+
+
+ c 1
+ d
+
+
+ '''
+
+ rendered = self.env['ir.qweb']._render(view.id)
+
+ self.assertEqual(str(rendered), result)
-from copy import deepcopy
class FileSystemLoader(object):
def __init__(self, path):
# TODO: support multiple files #add_file() + add cache
@@ -1141,14 +1610,52 @@ class FileSystemLoader(object):
def __call__(self, name, options):
for node in self.doc:
if node.get('t-name') == name:
- root = etree.Element('templates')
- root.append(deepcopy(node))
- arch = etree.tostring(root, encoding='unicode')
- return (arch, name)
+ return (deepcopy(node), name)
class TestQWebStaticXml(TransactionCase):
matcher = re.compile(r'^qweb-test-(.*)\.xml$')
+ def test_render_nodb(self):
+ """ Render an html page without db ans wihtout registry
+ """
+ expected = dedent("""
+
+
+ Odoo
+
+
+
+
+
+ """).strip()
+
+ templates = {
+ 'html': html.document_fromstring("""
+
+
+ Odoo
+
+
+
+
+
+ """),
+ 'content': html.fragment_fromstring("""
+
+
+
+ """)
+ }
+ def load(template_name, options):
+ return (templates[template_name], template_name)
+ rendering = render('html', {'val': 3}, load).strip()
+
+ self.assertEqual(html.document_fromstring(rendering), html.document_fromstring(expected))
+
@classmethod
def get_cases(cls):
path = cls.qweb_test_file_path()
@@ -1178,7 +1685,6 @@ class TestQWebStaticXml(TransactionCase):
self.env.user.tz = 'Europe/Brussels'
doc = etree.parse(path).getroot()
loader = FileSystemLoader(path)
- qweb = self.env['ir.qweb']
for template in loader:
if not template or template.startswith('_'):
continue
@@ -1187,39 +1693,24 @@ class TestQWebStaticXml(TransactionCase):
# so output is predictable & repeatable
params = {} if param is None else json.loads(param.text, object_pairs_hook=collections.OrderedDict)
- rec = re.compile(r'\>[ \n\t]*\<')
- result = doc.find('result[@id="{}"]'.format(template)).text
- self.assertEqual(
- rec.sub('><', qweb._render(template, values=params, load=loader).strip()),
- rec.sub('><', (result or u'').strip().replace('"', '"')),
- template
- )
+ def remove_space(text):
+ return re.compile(r'\>[ \n\t]*\<').sub('><', text.strip())
+
+ result = remove_space(doc.find('result[@id="{}"]'.format(template)).text or u'').replace('"', '"')
+ rendering = remove_space(self.env['ir.qweb']._render(template, values=params, load=loader))
+
+ self.assertEqual(rendering, result, template)
+
+ rendering_static = None
+ try:
+ rendering_static = remove_space(render(template, params, loader))
+ self.assertEqual(rendering_static, result, "%s (static rendering)" % template)
+ except QWebException as e:
+ if not isinstance(e.__cause__, NotImplementedError) and "Please use \"env['ir.qweb']._render\" method" in str(e):
+ raise
def load_tests(loader, suite, _):
# can't override TestQWebStaticXml.__dir__ because dir() called on *class* not
# instance
suite.addTests(TestQWebStaticXml.get_cases())
return suite
-
-class TestQWebMisc(TransactionCase):
- def test_render_comment_tail(self):
- """ Test the rendering of a tail text, near a comment.
- """
-
- view1 = self.env['ir.ui.view'].create({
- 'name': "dummy",
- 'type': "qweb",
- 'arch': """
-
-
-
- Text 1
-
- Text 2
- ok
-
- """
- })
- emptyline = '\n '
- expected = markupsafe.Markup('Text 1' + emptyline + emptyline + 'Text 2' + emptyline + 'ok')
- self.assertEqual(view1._render().strip(), expected)
diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
index 7818e6cc9db..92f115e775c 100644
--- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
+++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py
@@ -1056,7 +1056,7 @@ class TestAssetsManifest(AddonManifestPatched):
with self.assertRaises(Exception) as cm:
view._render()
self.assertTrue(
- "['test_assetsbundle/static/src/js/test_doesntexist.js'] not found" in cm.exception.message
+ "['test_assetsbundle/static/src/js/test_doesntexist.js'] not found" in str(cm.exception)
)
def test_09_remove_wholeglob(self):
@@ -1143,10 +1143,11 @@ class TestAssetsManifest(AddonManifestPatched):
with self.assertRaises(QWebException) as cm:
view._render()
- self.assertTrue(cm.exception.error)
- self.assertFalse(isinstance(cm.exception.error, RecursionError))
+ error = str(cm.exception.__cause__)
+ self.assertTrue(error)
+ self.assertFalse(isinstance(error, RecursionError))
self.assertTrue(
- 'Circular assets bundle declaration:' in cm.exception.message
+ 'Circular assets bundle declaration:' in error
)
def test_13_2_include_recursive_sibling(self):
@@ -1681,7 +1682,7 @@ class TestAssetsManifest(AddonManifestPatched):
with self.assertRaises(Exception) as cm:
view._render()
self.assertTrue(
- "test_assetsbundle/static/src/js/doesnt_exist.js not found" in cm.exception.message
+ "test_assetsbundle/static/src/js/doesnt_exist.js not found" in str(cm.exception)
)
def test_29_js_after_js_in_irasset_glob(self):
@@ -1790,7 +1791,7 @@ class TestAssetsManifest(AddonManifestPatched):
with self.assertRaises(QWebException) as cm:
view._render()
- self.assertTrue('Unallowed to fetch files from addon notinstalled_module' in cm.exception.message)
+ self.assertTrue('Unallowed to fetch files from addon notinstalled_module' in str(cm.exception))
def test_33bis_notinstalled_not_in_manifests(self):
self.env['ir.asset'].create({
diff --git a/odoo/tools/profiler.py b/odoo/tools/profiler.py
index 0c22ef415ce..85016b86cba 100644
--- a/odoo/tools/profiler.py
+++ b/odoo/tools/profiler.py
@@ -312,13 +312,12 @@ class QwebTracker():
@classmethod
def wrap_compile_directive(cls, method_compile_directive):
@functools.wraps(method_compile_directive)
- def _tracked_compile_directive(self, el, options, directive, indent):
- if not options.get('profile') or directive in ('inner-content', 'tag'):
- return method_compile_directive(self, el, options, directive, indent)
-
- enter = self._indent(f"self.env.context['qweb_tracker'].enter_directive({directive!r}, {el.attrib!r}, {options['last_path_node']!r})", indent)
- leave = self._indent("self.env.context['qweb_tracker'].leave_directive()", indent)
- code_directive = method_compile_directive(self, el, options, directive, indent)
+ def _tracked_compile_directive(self, el, options, directive, level):
+ if not options.get('profile') or directive in ('inner-content', 'tag-open', 'tag-close'):
+ return method_compile_directive(self, el, options, directive, level)
+ enter = f"{' ' * 4 * level}self.env.context['qweb_tracker'].enter_directive({directive!r}, {el.attrib!r}, {options['last_path_node']!r})"
+ leave = f"{' ' * 4 * level}self.env.context['qweb_tracker'].leave_directive()"
+ code_directive = method_compile_directive(self, el, options, directive, level)
return [enter, *code_directive, leave] if code_directive else []
return _tracked_compile_directive
@@ -336,19 +335,30 @@ class QwebTracker():
execution_context = None
if self.execution_context_enabled:
directive_info = {}
- if directive and ('t-' + directive) in attrib:
+ if ('t-' + directive) in attrib:
directive_info['t-' + directive] = repr(attrib['t-' + directive])
if directive == 'set':
if 't-value' in attrib:
directive_info['t-value'] = repr(attrib['t-value'])
if 't-valuef' in attrib:
directive_info['t-valuef'] = repr(attrib['t-valuef'])
+
+ for key in attrib:
+ if key.startswith('t-set-') or key.startswith('t-setf-'):
+ directive_info[key] = repr(attrib[key])
elif directive == 'foreach':
directive_info['t-as'] = repr(attrib['t-as'])
+ elif directive == 'att':
+ for key in attrib:
+ if key.startswith('t-att-') or key.startswith('t-attf-'):
+ directive_info[key] = repr(attrib[key])
elif directive == 'options':
- for key in list(attrib):
+ for key in attrib:
if key.startswith('t-options-'):
directive_info[key] = repr(attrib[key])
+ elif ('t-' + directive) not in attrib:
+ directive_info['t-' + directive] = None
+
execution_context = tools.profiler.ExecutionContext(**directive_info, xpath=xpath)
execution_context.__enter__()
self.context_stack.append(execution_context)
@@ -381,21 +391,36 @@ class QwebCollector(Collector):
def _get_directive_profiling_name(self, directive, attrib):
expr = ''
if directive == 'set':
- expr = f"t-set={repr(attrib['t-set'])}"
- if 't-value' in attrib:
- expr = f"{expr} t-value={repr(attrib['t-value'])}"
- if 't-valuef' in attrib:
- expr = f"{expr} t-valuef={repr(attrib['t-valuef'])}"
+ if 't-set' in attrib:
+ expr = f"t-set={repr(attrib['t-set'])}"
+ if 't-value' in attrib:
+ expr += f" t-value={repr(attrib['t-value'])}"
+ if 't-valuef' in attrib:
+ expr += f" t-valuef={repr(attrib['t-valuef'])}"
+ for key in attrib:
+ if key.startswith('t-set-') or key.startswith('t-setf-'):
+ if expr:
+ expr += ' '
+ expr += f"{key}={repr(attrib[key])}"
elif directive == 'foreach':
expr = f"t-foreach={repr(attrib['t-foreach'])} t-as={repr(attrib['t-as'])}"
elif directive == 'options':
if attrib.get('t-options'):
expr = f"t-options={repr(attrib['t-options'])}"
- for key in list(attrib):
+ for key in attrib:
if key.startswith('t-options-'):
expr = f"{expr} {key}={repr(attrib[key])}"
- elif directive and ('t-' + directive) in attrib:
+ elif directive == 'att':
+ for key in attrib:
+ if key == 't-att' or key.startswith('t-att-') or key.startswith('t-attf-'):
+ if expr:
+ expr += ' '
+ expr += f"{key}={repr(attrib[key])}"
+ elif ('t-' + directive) in attrib:
expr = f"t-{directive}={repr(attrib['t-' + directive])}"
+ else:
+ expr = f"t-{directive}"
+
return expr
def start(self):
diff --git a/odoo/tools/template_inheritance.py b/odoo/tools/template_inheritance.py
index 5506dd9875a..e6e62b78d88 100644
--- a/odoo/tools/template_inheritance.py
+++ b/odoo/tools/template_inheritance.py
@@ -4,11 +4,38 @@ from lxml.builder import E
import copy
import itertools
import logging
+import re
from odoo.tools.translate import _
from odoo.tools import SKIPPED_ELEMENT_TYPES, html_escape
_logger = logging.getLogger(__name__)
+RSTRIP_REGEXP = re.compile(r'\n[ \t]*$')
+
+def add_stripped_items_before(node, spec, extract):
+ text = spec.text or ''
+
+ before_text = ''
+ prev = node.getprevious()
+ if prev is None:
+ parent = node.getparent()
+ result = parent.text and RSTRIP_REGEXP.search(parent.text)
+ before_text = result.group(0) if result else ''
+ parent.text = (parent.text or '').rstrip() + text
+ else:
+ result = prev.tail and RSTRIP_REGEXP.search(prev.tail)
+ before_text = result.group(0) if result else ''
+ prev.tail = (prev.tail or '').rstrip() + text
+
+ if len(spec) > 0:
+ spec[-1].tail = (spec[-1].tail or "").rstrip() + before_text
+ else:
+ spec.text = (spec.text or "").rstrip() + before_text
+
+ for child in spec:
+ if child.get('position') == 'move':
+ child = extract(child)
+ node.addprevious(child)
def add_text_before(node, text):
@@ -17,22 +44,12 @@ def add_text_before(node, text):
return
prev = node.getprevious()
if prev is not None:
- prev.tail = (prev.tail or "").rstrip() + text
+ prev.tail = (prev.tail or "") + text
else:
parent = node.getparent()
parent.text = (parent.text or "").rstrip() + text
-def add_text_inside(node, text):
- """ Add text inside ``node``. """
- if text is None:
- return
- if len(node):
- node[-1].tail = (node[-1].tail or "").rstrip() + text
- else:
- node.text = (node.text or "") + text
-
-
def remove_element(node):
""" Remove ``node`` but not its tail, from its XML tree. """
add_text_before(node, node.tail)
@@ -210,28 +227,22 @@ def apply_inheritance_specs(source, specs_tree, inherit_branding=False, pre_loca
elif attribute in node.attrib:
del node.attrib[attribute]
elif pos == 'inside':
- add_text_inside(node, spec.text)
- for child in spec:
- if child.get('position') == 'move':
- child = extract(child)
- node.append(child)
+ # add a sentinel element at the end, insert content of spec
+ # before the sentinel, then remove the sentinel element
+ sentinel = E.sentinel()
+ node.append(sentinel)
+ add_stripped_items_before(sentinel, spec, extract)
+ remove_element(sentinel)
elif pos == 'after':
# add a sentinel element right after node, insert content of
# spec before the sentinel, then remove the sentinel element
sentinel = E.sentinel()
node.addnext(sentinel)
- add_text_before(sentinel, spec.text)
- for child in spec:
- if child.get('position') == 'move':
- child = extract(child)
- sentinel.addprevious(child)
+ add_stripped_items_before(sentinel, spec, extract)
remove_element(sentinel)
elif pos == 'before':
- add_text_before(node, spec.text)
- for child in spec:
- if child.get('position') == 'move':
- child = extract(child)
- node.addprevious(child)
+ add_stripped_items_before(node, spec, extract)
+
else:
raise ValueError(
_("Invalid position attribute: '%s'") %