From e830953570d5f28aee9bdcdf97af18d3e3246030 Mon Sep 17 00:00:00 2001 From: Gorash Date: Tue, 14 Dec 2021 08:21:25 +0000 Subject: [PATCH] [IMP] IrQweb: refactoring and add technical documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major changes: - Remove some of the recursively when compiling - Compile attributes became a directive - Compile options became a directive - `t-field` compilation now uses the same logic as `t-out` - Simplification of ``t-if`` directive compilation - Improved handling of errors wrapped by QWebException - Constants defined outside the class 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-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 ━┛ closes odoo/odoo#81024 Related: odoo/enterprise#22942 Signed-off-by: Christophe Monniez (moc) --- addons/http_routing/models/ir_http.py | 2 +- .../views/http_routing_template.xml | 4 +- addons/mail/models/ir_qweb.py | 11 +- addons/mail/models/mail_render_mixin.py | 7 +- .../views/project_update_templates.xml | 6 +- addons/stock/tests/test_report.py | 2 +- addons/web/controllers/main.py | 7 +- addons/web_editor/models/ir_qweb.py | 29 +- odoo/addons/base/models/ir_qweb.py | 1792 +++++++++++------ odoo/addons/base/tests/test_profiler.py | 29 +- odoo/addons/base/tests/test_qweb.py | 797 ++++++-- .../tests/test_assetsbundle.py | 13 +- odoo/tools/profiler.py | 57 +- odoo/tools/template_inheritance.py | 63 +- 14 files changed, 1939 insertions(+), 880 deletions(-) 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. +``` + +
10
+ + +
10
+
+ +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): + # + #