Edgewall Software

Version 17 (modified by Christian Boos, 8 years ago) ( diff )

moved the example to PortingFromGenshiToJinja/Example

Add Support for the Jinja2 Template Engine

We've decided some time ago to remove the legacy support for the ClearSilver template engine, for Trac 1.0 (r10570). Clearsilver had its share of inconveniences, enough that we decided to switch to the nicer Genshi template engine in 0.11, but to be honest ClearSilver was very fast and memory lenient. While we managed to keep Genshi memory usage somewhat in control (remember #6614?), the speed was never really adequate, especially for big changesets and for displaying source files over a few thousand lines of code (see TracDev/Performance#Genshi for details).

So one solution would be to switch once again, to a template engine that would combine the advantages of Genshi (pure Python, nice templates, flexible) and ClearSilver (speed!). Such a beast seems to exist now: Jinja2.

Several points remain to be clarified:

  • what will be the upgrade path for plugins that came to rely on ITemplateStreamFilters?
  • how to handle themeing? → see #Themeing below
  • should we rewrite tag builders or use lightweight string templates? → tag Fragment/Element builder has been reimplemented
  • others?

See also this Trac-Dev discussion from 2010, which is still pertinent. Well, obviously we managed to release Genshi 0.6 since then, but the issue is a recurring one, see this recent (2016-01) Genshi question on Trac-Users.

Experimenting with Jinja2 (2.8)

Nothing like a few numbers to make a point ;-)

These are the timings for rendering r3871, with the diff options set to side-by-side, in place modifications, served by tracd on my development laptop. This generates a page weighing 11.5MB (Genshi) to 10.3MB (Jinja2) in size.

Genshi Jinja2
stream blob generate stream (5) stream (10) stream (100) stream (1000) blob
1st 2nd 1st 2nd 1st 2nd 1st 2nd 1st 2nd 1st 2nd 1st 2nd 1st 2nd
TTFB 1660015670 25530 244602020 11602030 11602070 11702150 12302280 12303370 2450
CD 1609016050 387 12402820 27202730 26402730 26802470 23902350 2250 488 1060
Total3269031720 25917 257004840 38804760 38004800 38504620 36204630 34803850 3510
Rdr 235332327314771263

Some explanations:

  • Genshi (0.7 with speedups)
    • stream means we return content via Stream.serialize and send chunks as we have them
    • blob means we first generate all the content in memory with Stream.render, then send it at once
  • Jinja2 (2.8 with speedups)
    • generate means we use Template.generate and send chunks as we have them
    • stream means we use the TemplateBuffer wrapper on the above, which groups a few chunks (given by the number in parenthesis) together before we send them; for a chunk size of 100, we get the best compromise: still a very low TTFB and a reduced Content download time; actually the sweet spot is probably between 10 and 100, and will most certainly depend on the actual content (I just tested 75 which gives 1160/2430 for example)
    • blob means we first generate all the content in memory with Template.render
  • both:
    • 1st, is the time in ms for the first request, sent right after a server restart
    • 2nd, is the time in ms for the second request, sent just after the first (usually the 3rd and subsequent requests would show the same results as this 2nd request)

We measure:

  • TTFB (Time to first byte), as given by Chrome network panel in the developer window
  • CD (Content download), idem
  • Rdr (template rendering time), mostly significant for the "blob" method otherwise it also takes the network latency into account

All values are given in milliseconds.

Note that even if the total "blob" time seems better than the total "stream" one, the lower TTFB is nevertheless a major benefit for the streaming variant, as this means the secondary requests can start earlier (and in this case, finish before the main request).

In addition, while I didn't measure precisely the memory usage, Genshi made the python.exe process jump from 109MB to 239MB while rendering the request (blob). The memory seems to be freed afterwards (there were no concurrent requests). By contrast, with Jinja2 the memory spike was 106MB to 126MB.

In summary, this means that for the big problematic pages, we can easily have a 10x speedup and more, by migrating to Jinja2, and this with a much lighter memory footprint. For smaller pages, the speed-up is between 5x to 10x as well.

Implementation details

Themeing

We summarize the current template page architecture we use since the adoption of the Genshi template engine (i.e. since Trac 0.11), before describing the new but similar template page architecture that we'll use with the Jinja2 template engine.

Genshi theme

I've never really tried the TH:ThemeEnginePlugin plugin, or alternatives, so I can't be sure if I got it right, but from what I can see in Trac's code base itself, the idea with Genshi-based themeing (and page architecture in general) was to have a dynamically loaded "theme" template page that would primarily be in charge of the main structure of all HTML pages.

Let's take the example of a simple "end user" page, the search.html page. What happens is that:

  • search.html includes
    • layout.html, which includes
      • $chrome.theme (typically "theme.html", the default theme page that ships with Trac)

In more details, the search.html is structured like this:

<html>
  <xi:include href="layout.html" />
  <head>
    <title>Search</title>
    ...
  </head>
  <body>
    <div id="content" class="search">
      <h1>Search</h1>
      ...
    </div>
  </body>
</html>
  • it starts by including the layout.html page (<xi:include href="layout.html" />)
  • it then provides the <head> and <body> elements specific to that search page; these elements will be processed by the <py:match>es filters defined so far, in the order in which they have been included

The layout.html page in turn is structured like this:

<html>
  <py:match path="head"><head>
    <title py:with="title = list(select('title/text()'))">
      ${title}${project.name or 'Trac'}  <!-- e.g.  "Search - Trac" -->
    </title>
    ...
    ${select("*[local-name() != 'title']|text()|comment()")}
  </head></py:match>

  <py:match path="body"><body>

    ${select('*|text()|comment()')}         <!-- e.g. "<h1>Search</h1>"
                                                       ... -->
    ...
    <div id="altlinks" py:if="'alternate' in chrome.links">
      ...
    </div>
  </body></py:match>

  <xi:include href="$chrome.theme"><xi:fallback /></xi:include>
</html>      
  • it defines <py:match> filters for transforming the content provided in <head> and <body> elements in the including template (search.html); the head filter adds some content to the <title> and prepends some content in the <head>, the body filter appends some content in the <body>
  • it then dynamically includes some "theme" page, $chrome.theme

By default, this theme page will be our theme.html template:

<html>
  <body>
    <div id="banner">
      ...
    </div>
    <div id="main">
      ... 
      ${select('*|text()|comment()')}       <!-- e.g. "<h1>Search</h1>"
                                                       ... 
                                                       <div id="altlinks" ... </div> -->
    </div>
    <div id="footer">
      ...
    </div>
  </body>
</html>
  • it defines a <py:match> filter on the <body> tag (the one that will be produced by the previously applied filters, i.e. the output of the <py:match> from the layout.html) and it will embed that element into some predefined HTML structure (inside a div element), and it will prepend and append other divs around it:

So this dynamic theme template has the last say, and can theoretically re-order the content generated by the previous filters any way it likes, although in practice it simply inserts the body content produced by previous steps inside a predefined structure (the <div id="main">).

An example of another theme page: trac-bootstrap-theme's theme.html.

Note that this scheme can be extended to additional intermediate levels, for example see what we do with the admin panels.

One such panel is admin_basics.html:

<html>
  <xi:include href="admin.html" />
  <head>
    <title>Basics</title>
  </head>

  <body>
    ...
  </body>
</html>
  • it starts by including the admin.html page
  • then it provides the <head> and <body> elements specific to that panel

The admin.html page is similar to the search.html page in that it includes the layout.html, but it also first contains its own <py:match> templates to organize the content of the admin panel which included it:

<html>
  <py:match path="head" once="true"><head>
    <title>Administration: ${select('title/text()')}</title>
    ${select("*[local-name() != 'title']")}
  </head></py:match>

  <py:match path="body" once="true" buffer="false"><body>
    <div id="content" class="admin">
      <h1>Administration</h1>
      <div id="tabs">
      <div id="tabcontent">
        ${select("*|text()")}
        <br style="clear: right" />
      </div>
    </div>

  </body></py:match>

  <xi:include href="layout.html" />
</html>
  • defines <py:match> filters for <head> and <body> (of the including panel page), which in turn will produce modified <head> and <body> elements
  • it then includes the layout.html page (see above)

Feel free to brush up your Genshi craft by reading (GenshiTutorial#AddingaLayoutTemplate), as I just did ;-)

Jinja2 theme

Fortunately Jinja2 can do dynamic includes as well, or more precisely, dynamic extends. Therefore the same idea can be transposed: have the end user page extend the layout page, then have the layout page extend whatever has been defined to be the theme page.

The differences with Genshi are subtle: while in both case the control of the output is delegated to the more generic page, with Jinja2 the parent only controls what it puts around blocks. It can put some default content in these blocks, but the end user page has the final say about what to do with this default content, as it can reuse it inside its block (by calling super()) or not.

Let's transpose the previous example of the search.html template. For as long as we'll have both template engines coexisting, we'll prefix the new Jinja2 templates with a j. So we're now discussing:

  • jsearch.html, which extends
    • jlayout.html, which extends
      • jtheme.html (as this is our default theme page)

In more details, we describe what happens with the jsearch.html page:

# extends 'jlayout.html'

<!DOCTYPE html>
<html>
  <head>
    <title>
    #   block title
    ${_("Search")}    ${ super() }
    #   endblock title
    </title>

    # block head
    ${ super() }
    ...
    # endblock head
  </head>

  <body>
    # block content
    <div id="content" class="search">
      <h1>${_("Search")}</h1>
      ...
    </div>
  </body>
</html>
  • it starts by extending the jlayout.html page (`# extends 'jlayout.html')
  • then it redefines the title, head and content blocks, and has to place a ${ super() } expression in order to insert the default content proposed by the extended template at the right place; note that the presence of the <html>, <head> and <body> tags here is strictly "decorative", it will be ignored in the final output. As we're in a template extending another, only what's in the redefined blocks matters.

These blocks are first defined in the extended template, jlayout.html.

The jlayout.html page looks like this:

# extends ('j' + chrome.theme)

<!DOCTYPE html>
<html>
  <head>
    # block head

    <title>
    #  block title
    – ${project.name or 'Trac'}
    #  endblock title
    </title>

    ...
    # endblock head
  </head>

  <body>
    # block content
    # endblock content

    ...
    <div id="altlinks">
      ...
    </div>
  </body>
</html>
  • first dynamically extends in turn some "theme" page (# extends ('j' + chrome.theme))
  • then the jlayout.html template defines a few blocks:
    • the head block and its title sub-block; here we understand why we've put the title block outside of the head block in the jsearch.html template: the jlayout.html's head block contains among other things a <title> element, and we're reusing that default content in the inheriting head block; if in jsearch.html we had defined the <title> element in the head block as well, we would have had two of these <title> elements in that block
    • the content block which is filled with some predefined, generic content, mostly the same stuff that could be found in the corresponding layout.html, in <py:match> filters

By default, this theme template will be our jtheme.html page:

<!DOCTYPE html>
<html>
  <head>
    # block head
    # endblock head
  </head>

  <body>
    # block body
    <div id="banner">
      ...
    </div>
    <div id="main">
      ... 
      # block content
      (here goes the content of the content block produced by layout.html)
      # endblock content
    </div>
    <div id="footer">
      ...
    </div>
    # block body
  </body>
</html>
  • it defines a head block inside an otherwise empty <head> element; this means this is simply a "slot" that will be filled by the content of the head block in the extending templates (in this case, jlayout.html)
  • it contains a <body> element; as we want to replicate what the original theme.html did, what we want to achieve here is to provide a slot at the place where we want to insert the content produced by the jlayout.html template; I didn't name that inner block "body", as this could be confusing: we're not in control of the <body> element there, just of a fraction of it, the bottom part of the main div.

Note that if we wanted to be in full control of the body in the extending template, we could (as opposed to what you can do in Genshi): we would simply have to redefine the body block which contains all of the default structure (possibly reusing the content of that block by a call to ${ super() }). In our case, neither jsearch.html nor jlayout.html redefine the body block, as they're happy with what jtheme does with it.

Depending how one looks at it, it seems this approach is even more flexible than what we had in Genshi, as the end user template can decide which bits of the parent template it wants or not ("bottom-up" control), something that was not readily doable with Genshi ("top-down" control).

Like what we did with Genshi, this scheme can be extended to support additional intermediate levels. We did that for the admin and the preference panels.

Let's take for example the Logging admin panel, jadmin_logging.html:

# extends "jadmin.html"
<html>
  <head>
    <title>
      # block admintitle
      Logging
      # endblock admintitle
    </title>
  </head>

  <body>
    # block adminpanel
    ...
    # block adminpanel
  </body>
</html>
  • it starts by extending the jadmin.html page
  • then it provides the <head> and <body> elements specific to that panel, more precisely the admintitle and adminpanel blocks (if some JavaScript or other resources are needed, the head could be redefined as well)

The jadmin.html page is similar to the jsearch.html page in that it extends the jlayout.html:

# extends "jlayout.html"
<html>
  <head>
    <title>
      # block title
      Administration:
      #   block admintitle
      #   endblock admintitle 
      ${ super() }
      # endblock
    </title>
  </head>

  <body>
    # block content
    <div id="content" class="admin">
      <h1>Administration</h1>
      <div id="tabs">
      <div id="tabcontent">
        # block adminpanel
        # endblock adminpanel
        <br style="clear: right" />
      </div>
    </div>
    # endblock content
  </body>
</html>

See [1df4e05c/cboos.git] for the full conversion of admin.html → jadmin.html and admin_logging.html → jadmin_logging.html.

I omitted the discussion of the replacement for the <xi:include href="site.html> template (# include site_head.html and # include site_body.html).

Genshi to Jinja2 Migration

Some systematic comparison of the Genshi and Jinja2 template syntax can be seen in PortingFromGenshiToJinja#Changesinthetemplatesyntax (modeled after the old PortingFromClearSilverToGenshi page).

See also PortingFromGenshiToJinja/Example for a full example presented side-by-side.

jinjachecker

Jinja2 is very helpful when it detects any kind of error, as you always end up with a meaningful backtrace. Nevertheless, it can be tedious to get the nesting of control structures right.

To help with that, there's the contrib/jinjachecker.py tool.

python contrib/jinjachecker.py trac/wiki/templates/jwiki_view.html

It first analyzes the Jinja2 control structures, and tries to provide some helpful diagnostics in case of nesting or stylistic errors. It also adds curly braces to the statements, so if you have an editor which can do matching of brace pairs, you can quickly spot the origin o a nesting problem.

Finally, while the indentation of the statements is free in Jinja2 templates, being consistent with it also helps to ensure a proper nesting.

# -- Jinja2 check for 'trac/wiki/templates/jwiki_view.html'
   12    EXTENDS "jlayout.html"
   18          {BLOCK title
   19            {IF title:
   21            }IF
   22          }BLOCK title
   25        {BLOCK head
   26          SET modify_perm = 'WIKI_MODIFY' in perm(page.resource)
   27          SET is_not_latest = page.exists and page.version != latest_version
   31          {IF version or page.author == 'trac':
   33          }IF
   34          {IF modify_perm:
   40          }IF
   52        }BLOCK head
   56        {BLOCK content
   57          SET modify_perm = 'WIKI_MODIFY' in perm(page.resource)
   58          SET create_perm = 'WIKI_CREATE' in perm(page.resource)
   59          SET admin_perm = 'WIKI_ADMIN' in perm(page.resource)
   60          SET is_not_latest = page.exists and page.version != latest_version
   63          {IF version:
   67                {WITH
   68                  SET version = page.version
   69                  SET author = authorinfo(page.author)
   70                  SET date = pretty_dateinfo(page.time)
   71                  SET hef = href.wiki(page.name, action='diff', version=page.version)
   72                  {TRANS version, author, date, href
   77                  }TRANS
   78                }WITH
   84          }IF
   87            {IF page.exists:
   91              SET last_modification = (page.comment and
   97              {IF not version:
  100                {WITH
  101                  SET href = href.wiki(page.name, action='diff',
  103                  SET date = pretty_dateinfo(page.time)
  104                  {TRANS href, last_modification, date
  109                  }TRANS
  110                }WITH
  116              }IF
  117            ELSE:
  119              {TRANS name = name_of(page.resource)
  124              }TRANS
  126            }IF
  129          {WITH
  130            SET alist = attachments
  131            SET compact = True
  132            SET foldable = True
  133            INCLUDE "jlist_of_attachments.html"
  134          }WITH
  136          {WITH
  137            SET delete_perm = 'WIKI_DELETE' in perm(page.resource)
  138            SET rename_perm = 'WIKI_RENAME' in perm(page.resource)
  139            {IF modify_perm or create_perm or delete_perm:
  141              {IF modify_perm or create_perm:
  145                {IF is_not_latest and modify_perm:
  148                ELIF page.exists and modify_perm:
  151                ELIF not page.exists and create_perm:
  154                  {IF templates:
  161                    {FOR t in sorted(templates):
  165                    }FOR
  168                  }IF
  169                }IF
  173              {IF page.exists:
  174                {WITH alist = attachments
  175                  INCLUDE "jattach_file_form.html"
  176                }WITH
  177              }IF
  178            }IF
  180            {IF page.exists and rename_perm:
  187            }IF
  188            {IF page.exists and delete_perm:
  193                {IF page.version == latest_version:
  195                }IF
  199            }IF
  201          }IF
  202          }WITH
  204          {IF not page.exists and higher:
  208              {FOR markup in higher:
  210              }FOR
  213          }IF
  215          {IF not page.exists and related:
  219              {FOR markup in related:
  221              }FOR
  224          }IF
  230        }BLOCK content
# -- Jinja2 OK

As a second step, jinjachecker removes the Jinja2 markup and performs a validation of the document, using lxml.

# -- HTML check for 'trac/wiki/templates/jwiki_view.html'
    1 
...
   13 
   14 <!DOCTYPE html>
   15 <html>
   16   <head>
   17     <title>
   18 
   19 
   20       ${title} ${ super() }
   21 
   22 
   23     </title>
   24 
   25 
   26 
   27 
   28 
   29     ${ super() }
   30 
...
   52 
   53   </head>
   54 
   55   <body>
   56 
   57 
   58 
   59 
   60 
   61     <div id="content" class="${classes('wiki', create=not page.exists)}">
   62 
...
  213 
  214 
  215     </div>
  216 
  217     ${ super() }
  218 
  219 
  220   </body>
  221 </html>
# -- HTML OK
Note: See TracWiki for help on using the wiki.