Render templates with uTemplate

This commit is contained in:
Miguel Grinberg
2022-07-25 00:44:26 +01:00
parent 7f1e546067
commit 54c1329582
16 changed files with 513 additions and 9 deletions

2
.coveragerc Normal file
View File

@@ -0,0 +1,2 @@
[run]
omit=src/utemplate/*

View File

@@ -0,0 +1,17 @@
from microdot import Microdot, Response
from microdot_utemplate import render_template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return render_template('index.html', name=name)
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,17 @@
from microdot_asyncio import Microdot, Response
from microdot_utemplate import render_template
app = Microdot()
Response.default_content_type = 'text/html'
@app.route('/', methods=['GET', 'POST'])
async def index(req):
name = None
if req.method == 'POST':
name = req.form.get('name')
return render_template('index.html', name=name)
if __name__ == '__main__':
app.run()

View File

@@ -0,0 +1,20 @@
{% args name %}
<!doctype html>
<html>
<head>
<title>Microdot + uTemplate example</title>
</head>
<body>
<h1>Microdot + uTemplate example</h1>
{% if name %}
<p>Hello, <b>{{ name }}</b>!</p>
{% endif %}
<form method="POST">
<p>
What is your name?
<input type="text" name="name" autofocus />
</p>
<input type="submit" value="Submit" />
</form>
</body>
</html>

View File

@@ -374,6 +374,7 @@ class Response():
'txt': 'text/plain',
}
send_file_buffer_size = 1024
default_content_type = 'text/plain'
def __init__(self, body='', status_code=200, headers=None, reason=None):
if body is None and status_code == 200:
@@ -428,7 +429,7 @@ class Response():
'Content-Length' not in self.headers:
self.headers['Content-Length'] = str(len(self.body))
if 'Content-Type' not in self.headers:
self.headers['Content-Type'] = 'text/plain'
self.headers['Content-Type'] = self.default_content_type
def write(self, stream):
self.complete()

View File

@@ -149,6 +149,7 @@ class Response(BaseResponse):
def body_iter(self):
if hasattr(self.body, '__anext__'):
# response body is an async generator
return self.body
response = self
@@ -156,20 +157,28 @@ class Response(BaseResponse):
class iter:
def __aiter__(self):
if response.body:
self.i = 0
self.i = 0 # need to determine type of response.body
else:
self.i = -1
self.i = -1 # no response body
return self
async def __anext__(self):
if self.i == -1:
raise StopAsyncIteration
if self.i == 0:
if not hasattr(response.body, 'read'):
self.i = -1
return response.body
if hasattr(response.body, 'read'):
self.i = 2 # response body is a file-like object
elif hasattr(response.body, '__next__'):
self.i = 1 # response body is a sync generator
return next(response.body)
else:
self.i = 1
self.i = -1 # response body is a plain string
return response.body
elif self.i == 1:
try:
return next(response.body)
except StopIteration:
raise StopAsyncIteration
buf = response.body.read(response.send_file_buffer_size)
if _iscoroutine(buf): # pragma: no cover
buf = await buf

29
src/microdot_utemplate.py Normal file
View File

@@ -0,0 +1,29 @@
from utemplate import recompile
_loader = None
def init_templates(template_dir='templates', loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load templates
from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default is
the ``recompile.Loader`` class, which automatically
recompiles templates when they change.
"""
global _loader
_loader = loader_class(None, template_dir)
def render_template(template, *args, **kwargs):
if _loader is None: # pragma: no cover
init_templates()
render = _loader.load(template)
return render(*args, **kwargs)
def render_template_string(template, *args, **kwargs):
return ''.join(render_template(template, *args, **kwargs))

116
src/utemplate/README.md Normal file
View File

@@ -0,0 +1,116 @@
utemplate
=========
*Release: 1.4.1, Source: https://github.com/pfalcon/utemplate*
`utemplate` is a lightweight and memory-efficient template engine for
Python, primarily designed for use with Pycopy, a lightweight Python
implementation (https://github.com/pfalcon/pycopy). It is also fully
compatible with CPython and other compliant Python implementations.
`utemplate` syntax is roughly based on Django/Jinja2 syntax (e.g.
`{% if %}`, `{{var}}`), but only the most needed features are offered
(for example, "filters" (`{{var|filter}}`) are syntactic sugar for
function calls, and so far are not planned to be implemented, function
calls can be used directly instead: `{{filter(var)}}`).
`utemplate` compiles templates to Python source code, specifically to
a generator function which, being iterated over, produces consecutive
parts (substrings) of the rendered template. This allows for minimal
memory usage during template substitution (with Pycopy, it starts
from mere hundreds of bytes). Generated Python code can be imported as
a module directly, or a simple loader class (`utemplate.compiled.Loader`)
is provided for convenience.
There is also a loader class which will compile templates on the fly,
if not already compiled - `utemplate.source.Loader`.
Finally, there's a loader which will automatically recompile a template
module if source template is changed - `utemplate.recompile.Loader`.
This loader class is the most convenient to use during development, but
on the other hand, it performs extra processing not required for a
finished/deployed application.
To test/manage templates, `utemplate_util.py` tool is provided. For
example, to quickly try a template (assuming you are already in
`examples/` dir):
pycopy ../utemplate_util.py run squares.tpl
or
python3 ../utemplate_util.py run squares.tpl
Templates can take parameters (that's how dynamic content is generated).
Template parameters are passed as arguments to a generator function
produced from a template. They also can be passed on the `utemplate_util.py`
command line (arguments will be treated as strings in this case, but
can be of any types if called from your code):
pycopy ../utemplate_util.py run test1.tpl foo bar
Quick Syntax Reference
----------------------
Evaluating Python expression, converting it to a string and outputting to
rendered content:
* `{{<expr>}}`
Where `expr` is an arbitrary Python expression - from a bare variable name,
to function calls, `yield from`/`await` expressions, etc.
Supported statements:
* `{% args <var1>, <var2>, ... %}` - specify arguments to a template
(optional, should be at the beginning of a template if you want to
pass any arguments). All argument types as supported by Python can
be used: positional and keyword, with default values, `*args` and
`**kwargs` forms, etc.
* `{% if <expr> %}`, `{% elif <expr> %}`, `{% else %}`, `{% endif %}` -
similar to Python's `if` statement
* `{% for <var> in <expr> %}`, `{% endfor %}` - similar to Python's
`for` statement
* `{% while <expr> %}`, `{% endwhile %}` - similar to Python's `while`
statement
* `{% set <var> = <expr> %}` - assignment statement
* `{% include "name.tpl" %}` - statically include another template
* `{% include {{name}} %}` - dynamically include template whose name is
stored in variable `name`.
File Naming Conventions
-----------------------
* The recommended extension for templates is `.tpl`, e.g. `example.tpl`.
* When template is compiled, dot (`.`) in its name is replaced
with underscore (`_`) and `.py` appended, e.g. `example_tpl.py`. It
thus can be imported with `import example_tpl`.
* The name passed to `{% include %}` statement should be full name of
a template with extension, e.g. `{% include "example.tpl" %}`.
* For dynamic form of the `include`, a variable should similarly contain
a full name of the template, e.g. `{% set name = "example.tpl" %}` /
`{% include {{name}} %}`.
Examples
--------
`examples/squares.tpl` as mentioned in the usage examples above has the
following content:
```
{% args n=5 %}
{% for i in range(n) %}
| {{i}} | {{"%2d" % i ** 2}} |
{% endfor %}
```
More examples are available in the [examples/](examples/) directory.
If you want to see a complete example web application which uses `utemplate`,
refer to https://github.com/pfalcon/notes-pico .
License
-------
`utemplate` is written and maintained by Paul Sokolovsky. It's available
under the MIT license.

14
src/utemplate/compiled.py Normal file
View File

@@ -0,0 +1,14 @@
class Loader:
def __init__(self, pkg, dir):
if dir == ".":
dir = ""
else:
dir = dir.replace("/", ".") + "."
if pkg and pkg != "__main__":
dir = pkg + "." + dir
self.p = dir
def load(self, name):
name = name.replace(".", "_")
return __import__(self.p + name, None, None, (name,)).render

View File

@@ -0,0 +1,21 @@
# (c) 2014-2020 Paul Sokolovsky. MIT license.
try:
from uos import stat, remove
except:
from os import stat, remove
from . import source
class Loader(source.Loader):
def load(self, name):
o_path = self.pkg_path + self.compiled_path(name)
i_path = self.pkg_path + self.dir + "/" + name
try:
o_stat = stat(o_path)
i_stat = stat(i_path)
if i_stat[8] > o_stat[8]:
# input file is newer, remove output to force recompile
remove(o_path)
finally:
return super().load(name)

188
src/utemplate/source.py Normal file
View File

@@ -0,0 +1,188 @@
# (c) 2014-2019 Paul Sokolovsky. MIT license.
from . import compiled
class Compiler:
START_CHAR = "{"
STMNT = "%"
STMNT_END = "%}"
EXPR = "{"
EXPR_END = "}}"
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
self.file_in = file_in
self.file_out = file_out
self.loader = loader
self.seq = seq
self._indent = indent
self.stack = []
self.in_literal = False
self.flushed_header = False
self.args = "*a, **d"
def indent(self, adjust=0):
if not self.flushed_header:
self.flushed_header = True
self.indent()
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
self.stack.append("def")
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
def literal(self, s):
if not s:
return
if not self.in_literal:
self.indent()
self.file_out.write('yield """')
self.in_literal = True
self.file_out.write(s.replace('"', '\\"'))
def close_literal(self):
if self.in_literal:
self.file_out.write('"""\n')
self.in_literal = False
def render_expr(self, e):
self.indent()
self.file_out.write('yield str(' + e + ')\n')
def parse_statement(self, stmt):
tokens = stmt.split(None, 1)
if tokens[0] == "args":
if len(tokens) > 1:
self.args = tokens[1]
else:
self.args = ""
elif tokens[0] == "set":
self.indent()
self.file_out.write(stmt[3:].strip() + "\n")
elif tokens[0] == "include":
if not self.flushed_header:
# If there was no other output, we still need a header now
self.indent()
tokens = tokens[1].split(None, 1)
args = ""
if len(tokens) > 1:
args = tokens[1]
if tokens[0][0] == "{":
self.indent()
# "1" as fromlist param is uPy hack
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
self.indent()
self.file_out.write("yield from _.render(%s)\n" % args)
return
with self.loader.input_open(tokens[0][1:-1]) as inc:
self.seq += 1
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
inc_id = self.seq
self.seq = c.compile()
self.indent()
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
elif len(tokens) > 1:
if tokens[0] == "elif":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write(stmt + ":\n")
else:
self.indent()
self.file_out.write(stmt + ":\n")
self.stack.append(tokens[0])
else:
if stmt.startswith("end"):
assert self.stack[-1] == stmt[3:]
self.stack.pop(-1)
elif stmt == "else":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write("else:\n")
else:
assert False
def parse_line(self, l):
while l:
start = l.find(self.START_CHAR)
if start == -1:
self.literal(l)
return
self.literal(l[:start])
self.close_literal()
sel = l[start + 1]
#print("*%s=%s=" % (sel, EXPR))
if sel == self.STMNT:
end = l.find(self.STMNT_END)
assert end > 0
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
self.parse_statement(stmt)
end += len(self.STMNT_END)
l = l[end:]
if not self.in_literal and l == "\n":
break
elif sel == self.EXPR:
# print("EXPR")
end = l.find(self.EXPR_END)
assert end > 0
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
self.render_expr(expr)
end += len(self.EXPR_END)
l = l[end:]
else:
self.literal(l[start])
l = l[start + 1:]
def header(self):
self.file_out.write("# Autogenerated file\n")
def compile(self):
self.header()
for l in self.file_in:
self.parse_line(l)
self.close_literal()
return self.seq
class Loader(compiled.Loader):
def __init__(self, pkg, dir):
super().__init__(pkg, dir)
self.dir = dir
if pkg == "__main__":
# if pkg isn't really a package, don't bother to use it
# it means we're running from "filesystem directory", not
# from a package.
pkg = None
self.pkg_path = ""
if pkg:
p = __import__(pkg)
if isinstance(p.__path__, str):
# uPy
self.pkg_path = p.__path__
else:
# CPy
self.pkg_path = p.__path__[0]
self.pkg_path += "/"
def input_open(self, template):
path = self.pkg_path + self.dir + "/" + template
return open(path)
def compiled_path(self, template):
return self.dir + "/" + template.replace(".", "_") + ".py"
def load(self, name):
try:
return super().load(name)
except (OSError, ImportError):
pass
compiled_path = self.pkg_path + self.compiled_path(name)
f_in = self.input_open(name)
f_out = open(compiled_path, "w")
c = Compiler(f_in, f_out, loader=self)
c.compile()
f_in.close()
f_out.close()
return super().load(name)

View File

@@ -7,3 +7,5 @@ from tests.microdot.test_microdot import TestMicrodot
from tests.microdot_asyncio.test_request_asyncio import TestRequestAsync
from tests.microdot_asyncio.test_response_asyncio import TestResponseAsync
from tests.microdot_asyncio.test_microdot_asyncio import TestMicrodotAsync
from tests.microdot_utemplate.test_utemplate import TestUTemplate

View File

@@ -0,0 +1,2 @@
{% args name %}
Hello, {{ name }}!

View File

@@ -0,0 +1,6 @@
# Autogenerated file
def render(name):
yield """Hello, """
yield str(name)
yield """!
"""

View File

@@ -0,0 +1,60 @@
try:
import uasyncio as asyncio
except ImportError:
import asyncio
import unittest
from microdot import Microdot, Request
from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync
from microdot_utemplate import render_template, render_template_string, \
init_templates
from tests.mock_socket import get_request_fd, get_async_request_fd
init_templates('tests/microdot_utemplate/templates')
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
class TestUTemplate(unittest.TestCase):
def test_render_template(self):
s = list(render_template('hello.txt', name='foo'))
self.assertEqual(s, ['Hello, ', 'foo', '!\n'])
def test_render_template_string(self):
s = render_template_string('hello.txt', name='foo')
self.assertEqual(s.strip(), 'Hello, foo!')
def test_render_template_in_app(self):
app = Microdot()
@app.route('/')
def index(req):
return render_template('hello.txt', name='foo')
req = Request.create(app, get_request_fd('GET', '/'), 'addr')
res = app.dispatch_request(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(list(res.body_iter()), ['Hello, ', 'foo', '!\n'])
def test_render_template_in_app_async(self):
app = MicrodotAsync()
@app.route('/')
async def index(req):
return render_template('hello.txt', name='foo')
req = _run(RequestAsync.create(
app, get_async_request_fd('GET', '/'), 'addr'))
res = _run(app.dispatch_request(req))
self.assertEqual(res.status_code, 200)
async def get_result():
result = []
async for chunk in res.body_iter():
result.append(chunk)
return result
result = _run(get_result())
self.assertEqual(result, ['Hello, ', 'foo', '!\n'])

View File

@@ -15,7 +15,7 @@ python =
[testenv]
commands=
pip install -e .
pytest -p no:logging --cov=src --cov-branch --cov-report=term-missing
pytest -p no:logging --cov=src --cov-config=.coveragerc --cov-branch --cov-report=term-missing
deps=
pytest
pytest-cov
@@ -24,7 +24,7 @@ deps=
deps=
flake8
commands=
flake8 --ignore=W503 --exclude tests/libs src tests examples
flake8 --ignore=W503 --exclude src/utemplate,tests/libs src tests examples
[testenv:upy]
whitelist_externals=sh