Python 3.14 introduced t-strings (PEP 750), a new string prefix that creates Template objects instead of performing immediate string interpolation. Quixote provides the htmlformat() function which takes a t-string and returns an htmltext instance with automatic HTML escaping of interpolated values.
This approach provides the same safety benefits as PTL's HTML templates (see doc/PTL.txt) but uses standard Python syntax -- no import hook or .ptl file extension is needed.
The htmlformat() function is typically imported with the short alias H:
from quixote.html import htmlformat as H
It accepts a t-string (prefixed with t) and returns htmltext. The literal parts of the t-string are treated as trusted HTML markup, while interpolated values are automatically escaped:
>>> from quixote.html import htmlformat as H
>>> title = 'Dissertation on <HEAD>'
>>> H(t'<h1>{title}</h1>')
<htmltext '<h1>Dissertation on <HEAD></h1>'>
The htmlformat() function only accepts t-strings. Passing a regular string will raise a TypeError.
For functions that build up HTML from multiple parts, use htmltemplate() to create an accumulator. This returns a TemplateIO object in HTML mode. Append to it with += and call .getvalue() to retrieve the final htmltext result:
from quixote.html import htmlformat as H
from quixote.html import htmltemplate
def meta_tags(title, description):
ht = htmltemplate()
ht += H(t'<title>{title}</title>')
ht += H(t'<meta name="description" content="{description}">\n')
return ht.getvalue()
This is equivalent to the following PTL template:
@ptl_html
def meta_tags(title, description):
F'<title>{title}</title>'
F'<meta name="description" content="{description}">\n'
In both cases, the title and description values are automatically HTML-escaped:
>>> meta_tags('Catalog', 'A catalog of our cool products')
<htmltext '<title>Catalog</title>
<meta name="description" content="A catalog of our cool products">\n'>
>>> meta_tags('Dissertation on <HEAD>',
... 'Discusses the "LINK" and "META" tags')
<htmltext '<title>Dissertation on <HEAD></title>
<meta name="description"
content="Discusses the "LINK" and "META" tags">\n'>
Control flow works naturally since the code is regular Python:
from quixote.html import htmlformat as H
from quixote.html import htmltemplate
def user_list(users):
ht = htmltemplate()
ht += H(t'<ul>\n')
for user in users:
ht += H(t' <li>{user.name}</li>\n')
ht += H(t'</ul>\n')
return ht.getvalue()
For simple cases that produce a single piece of HTML, you can use htmlformat() directly without htmltemplate():
def format_title(title):
return H(t'<h1>{title}</h1>')
If you need to include HTML markup from an external source (e.g. a file or database), wrap it with htmltext() before interpolating:
from quixote.html import htmlformat as H
from quixote.html import htmltext
def render_page(body_html):
safe_body = htmltext(body_html)
return H(t'<html><body>{safe_body}</body></html>')
Values that are already htmltext instances will not be double-escaped.
As with PTL, use htmltext() sparingly -- every call is an assertion that the data is safe and does not contain malicious HTML.
Two scripts are provided for converting existing .ptl files to standard Python using t-strings:
The recommended conversion procedure is:
Run fstring_to_tstring.py first to convert F-string literals:
python tools/fstring_to_tstring.py -w yourmodule.ptl
Run ptl_to_py.py to convert the decorated functions:
python tools/ptl_to_py.py -w yourmodule.ptl
Rename .ptl files to .py (if not already done by your workflow).
Use ruff to clean up formatting and imports:
ruff check --fix yourmodule.py ruff format yourmodule.py
Both scripts support a -v flag to preview changes on stdout without modifying files. Without -w, the converted source is printed to stdout for review. When -w is used, the original file is saved with a ~ suffix as a backup.
Given a PTL file header.ptl:
from quixote.ptl import ptl_html
@ptl_html
def page_header(title, css_url):
F'<head>\n'
F' <title>{title}</title>\n'
F' <link rel="stylesheet" href="{css_url}">\n'
F'</head>\n'
After running both conversion scripts, the result is:
from quixote.html import htmlformat as H
from quixote.html import htmltemplate
def page_header(title, css_url):
ht = htmltemplate()
ht += H(t'<head>\n')
ht += H(t' <title>{title}</title>\n')
ht += H(t' <link rel="stylesheet" href="{css_url}">\n')
ht += H(t'</head>\n')
return ht.getvalue()