Introduction
So, the other day, I was at a local Python meetup, where one of the members shared an interesting project prompt:
Create a minimalist static site generator using only the Python standard library.
Of course, one option would be to write a tokenizer and parser from scratch, parsing your template language of choice quickly and efficiently. This has the merit of being the most technically challenging way to approach this that I can think of, and allowing for complete creative control. However, this approach also seems to carry a significant amount of work for a fun hobby project.
The person who introduced the prompt took it in an interesting direction, choosing XML as the templating language, and then using Python’s native XML parsing capabilities to generate XHTML documents, championing XML’s validity in 2023. I like this approach, as I believe it was a good learning experience regarding Python’s XML support, though he said that he did end up writing more error-checking code for invalid XML structures than he would have liked.
On the other hand, I felt inspired to take this approach in an more minimalist direction: Using the Python parser itself to handle template generation.
Ruby Inspiration
It was interesting to me to see what is required to build template generators in Python, as, in my experience, Ruby makes this type of project trivial. The site you’re reading right now is generated by a bodged-together static site generator I wrote using Ruby’s standard library.
Notably, Ruby’s standard library includes the templating system ERB, so including a template is as simple as including just a few lines.
ERB has two main features that let you use Ruby code to produce HTML:
- Execution tags:
<% ... %>
- Used for loops, comments, if statements, function definitions, and anything that shouldn’t end up in the result.
- Expression tags:
<%= ... %>
- Used to produce values for your HTML
With whitespace removed, this produces:
Instead of having a separate templating language, you just use normal Ruby code. I wanted to find an easy way to get the same vibe in Python.
PHP Inspiration
As a disclaimer, I really haven’t used PHP very much, so I didn’t take any influences from PHP beyond the name.
Originally, I wanted to call this project “PHP”, but I found it caused more confusion than laughs, so PyHP felt like a good compromise.
I’m nowhere near the first person to think of this pun, so credit to the following people for getting there first:
My Solution
While it’s totally possible to try to build out a full static site generator under this prompt, I was mostly inspired to find an elegant, minimalist solution to produce basic templates.
However, because I’m working in Python, I chose to loosely base the project on Jinja templates:
Syntax | Description |
---|---|
{{ ... }} | expression tag |
{% ... %} | execution tag |
In general, I figured the flow should go as follows:
Given this framework, Python gives us two really useful tools for processing strings as Python code, and they happen to be exactly what we want!
Function | Description |
---|---|
eval | evaluate a string and return a result |
exec | execute a string and return None |
Basic usage is as follows:
exec
and eval
both take three parameters:
- The code we’re executing
- the global definitions the code has access to
- the local definitions the code has access to
In our case, we want to be able to call global functions with ease, so we pass in all globals()
for parameter 2. We also want to be able to define new methods and variables on the fly, so we pass in a shared namespace for all templates as well.
Based on this reasoning, we get the following core solution:
I felt like this was a pretty elegant solution to this prompt.
Let’s see what it can do!
Examples
Basic Example
<p>
{% from datetime import datetime %}
{{ datetime.now().strftime("%Y-%m-%d") }}
</p>
When evaluated with python3 src/main.py --file $(realpath example_site/example.php)
, this will print out:
Including templates
To handle templates, we just have to define a function that looks for a filename, and returns the file as a string.
Something like
will allow us to easily include other files in templates.
{{ include('./head.php') }}
This example will include the entire ”./head.php” file in whatever template we’re using.
Given these basic building blocks, it should be possible to create many types of static sites.
Putting files together
.
├── footer.php
├── head.php
└── index.php
footer.php
:
head.php
:
index.php
:
When index.php
is processed, it will print:
Defining functions
I was initially wary about using a whitespace-sensitive language for use in html templating, as I wasn’t sure if HTML indentation would cause problems.
Luckily, Python does not require consistent depths between different levels of indentation.
The following is a weird, but still valid, function definition:
This gives a certain level of freedom to how functions are defined in PyHP.
Additionally, there are some annoying scoping limitations when working with exec
. For example, the following code works as expected, between exec
and eval
:
{% def f(x):
return x + 1
%}
{{f(1) # returns 2}}
but the following code errors out:
{%
def f(x):
return x + 1
def g(x):
return f(x) + 1
%}
{{g(1)}}
NameError: name 'f' is not defined
The best way I’ve found to resolve this is to force the definition by assigning it to globals()
:
{%
def f(x):
return x + 1
globals()['f'] = f
def g(x):
return f(x) + 1
%}
{{g(1) # returns 3 }}
But, that’s a bit ugly. To resolve this scoping issue, there’s a simple decorator you can use instead!
{%
@define
def f(x):
return x + 1
def g(x):
return f(x) + 1
%}
{{g(1) # returns 3 }}
Email obfuscation example
Imagine a situation where someone wanted to put their email on their blog, but didn’t want their email to be accessible by people scraping the website, so they put a bunch of garbage in the text and then cleaned it up with CSS.
produces:
Conclusion
Anyway, that’s all I have for now! I hope you found this at least mildly interesting. 😊
This is still a super janky project, but I thought it was interesting how it’s possible to get pretty useful results from such as simple approach.
Just don’t create a template that deletes everything!
<!-- This file deletes main.py -->
{% import os %}
{% os.remove(__file__) %}