Templating Your Python Output with Chameleon

Especially in the web app world it is important to use a templating engine to handle the output finalization. It makes it possible to manage consistent headers, footers and styling details across all the pages in the app. You know, all those <div>, <span>, <h1> and other tags that are used to format the output your app produces, in addition to the menus and links shown in every page.

Without templates you would need to ensure that your app always prints out the required strings to render all those common elements for each page. With templates the goal is that you define the common elements and details once in the template(s), and then your app uses the templates to create the final HTML output.

For other types of applications templating can be useful too. Previously I’ve just used the Python str.format() method for outputs, like this:

site = "HQ"
network_list = ["10.1.0.0/24", "10.1.1.0/24"]

networks_output = ""
for network in network_list:
    networks_output += f"- {network}\n"

output_template = """Site: {sitename}

Networks:
{networks}"""

print(output_template.format(
    sitename=site,
    networks=networks_output,
))

which produces this output:

Site: HQ

Networks:
- 10.1.0.0/24
- 10.1.1.0/24

This is not exactly the most elegant way to do it, but it works, and requires no external libraries. See how the output formatting is actually created in two places: first the networks are iterated to create a list-formatted string of them (networks_output), and then the site name and the textual network list is fed to the output template.

Now, let’s see chameleon. It is an HTML/XML template engine for Python (direct quote from the documentation), and it uses page templates language that is usually used within the HTML/XML tags.

Most of the documentation and examples of using chameleon concentrates around the HTML/XML world, but it can be used without any output tags as well.

First let’s create the virtual environment and the directory structure to develop in:

markku@devel:~/devel$ mkdir chameleon-test
markku@devel:~/devel$ cd chameleon-test/
markku@devel:~/devel/chameleon-test$ python3 -m venv venv
markku@devel:~/devel/chameleon-test$ . venv/bin/activate
(venv) markku@devel:~/devel/chameleon-test$ pip install -U pip wheel
...
Successfully installed pip-21.0.1 wheel-0.36.2
(venv) markku@devel:~/devel/chameleon-test$ pip install chameleon
...
Successfully installed chameleon-3.9.0
(venv) markku@devel:~/devel/chameleon-test$ mkdir templates
(venv) markku@devel:~/devel/chameleon-test$ ls
templates  venv
(venv) markku@devel:~/devel/chameleon-test$

Now let’s create our code, chamtest.py:

from pathlib import Path

from chameleon import PageTemplateLoader

templates_path = Path(__file__).resolve().parent / "templates"
template_loader = PageTemplateLoader(
    str(templates_path),
    ".pt",
)
template = template_loader["site_networks"]

site = "HQ"
network_list = ["10.1.0.0/24", "10.1.1.0/24"]

print(template(sitename=site, networks=network_list))

I realize that there are many small details maybe not familiar to all readers, so let’s look at them:

  • pathlib is used for finding the absolute directory name for the templates directory (people used to working with legacy Python have traditionally used os module functions to do the same operations, pathlib is a Python 3.4+ feature so “only” 7 years old or so):
    • __file__ is the current Python file that is being executed
    • Path(__file__) is the Path object representative of the current file name
    • .resolve() returns the absolute path name of the file (like /home/markku/devel/chameleon-test/chamtest.py instead of just chamtest.py)
    • .parent returns the parent object, which in this case is the directory where the file is
    • / "templates" adds the name of our templates directory in the Path object (in an OS-independent way)
  • PageTemplateLoader() call tells chameleon where to look for the templates and what is the default extension in the template file names
    • chameleon does not yet support Path objects so our Path object is converted to a string
    • .pt means page template (it took me a long time to figure that out so there you have it!)
  • The actual template file in this example will be site_networks.pt in our templates directory
  • The print() function calls template with sitename and networks arguments (defined below in the template)
  • All in all using the template is very easy compared to the earlier .format() example, after the template loader has once been setup in the app.

This is our actual template file, templates/site_networks.pt:

Site: <tal:block content="sitename" />

Networks:
<tal:block tal:repeat="network networks">- ${network}
</tal:block>

Let’s run our code to verify that it does what we want before looking at the template:

(venv) markku@devel:~/devel/chameleon-test$ python3 chamtest.py
Site: HQ

Networks:
- 10.1.0.0/24
- 10.1.1.0/24

(venv) markku@devel:~/devel/chameleon-test$

Looks fine for us. You can stop reading here if this practical example is all you wanted to know!

Now let’s explain our template and the language a bit, by going back to the template language itself. In the template attribute language (TAL) one way to output the contents of a variable is like this:

Site: <div tal:content="sitename">placeholder for sitename</div>

and that will output:

Site: <div>HQ</div>

You see that the contents of the tag (the long placeholder text) was replaced by the value of the sitename variable, that’s how the tal:content attribute works.

But in the output there are the <div> tags we did not want in our use case, so we can omit them by adding the tal:omit-tag attribute:

Site: <div tal:content="sitename" tal:omit-tag="True">placeholder</div>

which gives as plain “Site: HQ” as output.

The generic idea of this template language (as far as I understand it!) is that most of the templating commands are embedded within the existing HTML or XML tags, instead of using extra commands (like the {% %} blocks in Jinja2). This leads us to use the tal:omit-tag syntax (as demonstrated above) if we don’t want the tags in the output at all.

For the use cases where the tags are not needed at all, there is another way, documented here (and already used in our template file example above):

Site: <tal:block content="sitename">placeholder</tal:block>

or even shorter:

Site: <tal:block content="sitename" />

Both of those will output what we wanted (“Site: HQ“, with no tags).

There is also small piece of information here: “Whenever the tal or metal namespaces are used with an element name that is not recognized, the effect is the same of tal:omit-tag – that is, the element tag is omitted.” Let’s test it and replace “block” with “sitename“:

Site: <tal:sitename content="sitename" />

The result is still the same as earlier: “Site: HQ

The benefit of that last feature will become somewhat useful later in this post.

Meanwhile, in our network list example we printed the networks line by line. But what if we wanted to print them in the same line, comma-separated? Let’s try, but we’ll use the tal:omit-tag syntax again (because that’s how the chameleon examples usually works as I see it):

Networks: <div tal:repeat="network networks"
               tal:omit-tag="True">${network}, </div>
(I split the line to make it clear what's inside the div tag)

That gives us:

Networks: 10.1.0.0/24,
          10.1.1.0/24,

which is not what we expected. Where did the line break come from? And the indentation? Both of them seem to be features of chameleon (or the templating language). You need to remember that the normal use case for this is HTML/XML where the whitespaces don’t matter that much, as for the formatting you will use various tags instead of adding or removing whitespaces. As far as I understand it, the only way to remove the whitespaces (for our plaintext output purposes) is to use the <tal:block> (or <tal:my_own_keyword>) syntax, so here it is:

Networks: <tal:block tal:repeat="network networks">${network}, </tal:block>

which gives us:

Networks: 10.1.0.0/24, 10.1.1.0/24,

Almost there! The tal:repeat attribute takes the values of networks (our input variable for the template) one at the time, setting the value for the network variable in each round, and the tag contents output it with a trailing comma and space. But we don’t want the comma (and the space) after the last element of the list.

That’s what tal:condition attribute is for:

Networks: <tal:block tal:repeat="network networks">${network}<tal:block tal:condition="not:repeat.network.end">, </tal:block></tal:block>

Wow, looks complicated, but let’s see:

  • The first tal:block has the repeat loop and outputs the network (${network})
  • Then (before closing the repeat loop tal:block) there is a new tal:block with the condition: not:repeat.network.end. It is true for all cases but the last round in the repeat loop, so it will output the comma and the space in all other cases than the last one.
  • And finally both tal:blocks are closed.

The result:

Networks: 10.1.0.0/24, 10.1.1.0/24

So it worked! But if we want to write the template statement in a more clear way we can add some line breaks within the tags (as they are XML even though our resulting text is not) and we can also rename the tal:blocks:

Networks: <tal:repeat_loop
    tal:repeat="network networks">${network}<tal:comma_output
        tal:condition="not:repeat.network.end">, </tal:comma_output></tal:repeat_loop>

(I do realize that the blog theme will limit the line lengths anyway so the output here will not be the best…)

Well, at least the tag names are more descriptive, but do remember that you can also confuse someone with those. The output is the same as earlier:

Networks: 10.1.0.0/24, 10.1.1.0/24

That concludes the example of using chameleon with plaintext-outputting templates.

Leave a Reply