25 Jun, 2024
Here's a very simple template language I like. All it does is escape values, pop in blocks of already-escaped markup, and dynamically generate some blocks of markup based on a context. This means from the template author's perspective, they just have to put {thing}
tags in the places they want the thing.
Directory tree:
.
├── README.md
├── index_aio.py
├── pyproject.toml
├── test
│ └── index_aio_test.py
└── tree-and-cat.sh
2 directories, 5 files
File .gitignore
:
static_template.egg-info
.venv
.tox
File README.md
:
# Static Template
```sh
pip install -e ".[testing]"
tox
```
File index_aio.py
:
from typing import Protocol, AsyncContextManager, Type, Optional, IO, Any, Dict, Callable, Coroutine, List, AsyncGenerator
import asyncio
import os
import re
from contextlib import asynccontextmanager
class AsyncFileReader(Protocol):
async def read(self) -> str:
...
class AsyncFileOpener(Protocol):
def open(self, filename: str) -> AsyncContextManager[AsyncFileReader]:
...
files: AsyncFileOpener
try:
import aiofiles
class AiofilesReader:
def __init__(self, file):
self.file = file
async def read(self) -> str:
return await self.file.read()
class AiofilesOpener:
@asynccontextmanager
async def open(self, filename: str) -> AsyncGenerator[AiofilesReader, None]:
async with aiofiles.open(filename, 'r') as f:
yield AiofilesReader(f)
files = AiofilesOpener()
except ImportError:
class BlockingReader:
def __init__(self, file):
self.file = file
async def read(self) -> str:
return self.file.read()
class BlockingOpener:
@asynccontextmanager
async def open(self, filename: str) -> AsyncGenerator[BlockingReader, None]:
file = open(filename, 'r')
try:
yield BlockingReader(file)
finally:
file.close()
files = BlockingOpener()
def escape_html(s: str) -> str:
"""Escape HTML characters in a string."""
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
class Template:
def __init__(self, template_dir: str, dynamic_markup: Optional[Dict[str, Callable[..., str]]] = None):
self.template_dir = template_dir
self.template_cache: Dict[str, Dict[str, Any]] = {}
self.include_cache: Dict[str, str] = {}
self.dynamic_markup = dynamic_markup or {}
async def load_template(self, file_path: str) -> str:
async with files.open(file_path) as file:
return await file.read()
async def load_includes(self) -> None:
if not self.include_cache:
includes_dir = os.path.join(self.template_dir, 'includes')
include_files = await asyncio.to_thread(os.listdir, includes_dir)
tasks = [self._load_include(os.path.join(includes_dir, file_name), file_name) for file_name in include_files]
await asyncio.gather(*tasks)
async def _load_include(self, include_path: str, file_name: str) -> None:
async with files.open(include_path) as file:
include_content = (await file.read()).rstrip('\n')
include_name = os.path.splitext(file_name)[0]
self.include_cache[include_name] = include_content
async def replace_includes(self, template: str, limit: int = 1000) -> str:
regex = re.compile(r'{([^{}\s]+)}')
has_includes = True
iterations = 0
while has_includes:
has_includes = False
iterations += 1
if iterations > limit:
raise ValueError('Circular include detected.')
template = regex.sub(lambda match: self.include_cache.get(match.group(1), match.group(0)), template)
has_includes = any(match.group(1) in self.include_cache for match in regex.finditer(template))
return template
async def compile_template(self, template_name: str) -> Dict[str, List[str]]:
if template_name not in self.template_cache:
template_path = os.path.join(self.template_dir, f'{template_name}.html')
template = await self.load_template(template_path)
await self.load_includes()
template = await self.replace_includes(template)
segments = []
placeholders = []
last_index = 0
regex = re.compile(r'{([^{}\s]+)}')
for match in regex.finditer(template):
segments.append(template[last_index:match.start()])
placeholders.append(match.group(1).strip())
last_index = match.end()
segments.append(template[last_index:])
self.template_cache[template_name] = {'segments': segments, 'placeholders': placeholders}
return self.template_cache[template_name]
async def render(self, template_name: str, values: Dict[str, str], markup: Dict[str, str], dynamic_markup_context: Optional[Dict[str, Any]] = None) -> str:
dynamic_markup_context = dynamic_markup_context or {}
compiled = await self.compile_template(template_name)
segments = compiled['segments']
placeholders = compiled['placeholders']
result = ''
all_keys = set(values) | set(markup) | set(self.dynamic_markup)
if len(all_keys) != len(values) + len(markup) + len(self.dynamic_markup):
raise ValueError('Duplicate key detected.')
for key in all_keys:
if key in self.include_cache:
raise ValueError(f"Key conflict detected: '{key}' is both a variable and an include.")
for i, placeholder in enumerate(placeholders):
if placeholder in values:
result += segments[i] + escape_html(values[placeholder])
elif placeholder in markup:
result += segments[i] + markup[placeholder]
elif placeholder in self.dynamic_markup:
func = self.dynamic_markup[placeholder]
if asyncio.iscoroutinefunction(func):
result += segments[i] + await func(**dynamic_markup_context)
else:
result += segments[i] + func(**dynamic_markup_context)
else:
result += segments[i] + f'{{{placeholder}}}'
result += segments[-1]
return result
File pyproject.toml
:
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.tox]
legacy_tox_ini = """
[tox]
envlist = py{311,312}-{aiofiles,noaiofiles}
[testenv]
deps =
aiofiles: aiofiles
commands = python3 test/index_aio_test.py
setenv =
"""
[project]
name = "static-template"
version = "0.1.0"
description = "A very simple static template format for simple values, blocks of markup, includes and dynamic markup, but no logic"
authors = [
{ name = "James Gardner" }
]
[project.optional-dependencies]
testing = [
"tox",
]
File test/index_aio_test.py
:
import unittest
import tempfile
import os
import asyncio
from index_aio import Template, escape_html
class TestTemplateRendering(unittest.TestCase):
def setUp(self):
# Create a temporary directory
self.test_dir = tempfile.TemporaryDirectory()
# Set up the directory structure and files
os.makedirs(os.path.join(self.test_dir.name, 'templates', 'includes'))
files = {
'templates/main.html': """<!DOCTYPE html>
<html lang="en">
<head>
<title>{title}</title>
{head}
</head>
<body>
{content}
{hello}
{ahello}
{bye}
{footer}
{{{ <b>Hi</b> }
{ <p> Hello </p> }}
</body>
</html>""",
'templates/includes/footer.html': """<footer>
<p>Footer content here</p>
</footer>""",
'templates/includes/head.html': """<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
Head
{new}""",
'templates/includes/new.html': """New
{ newer}""",
'templates/includes/newer.html': """Newer
{ none}"""
}
for file_path, content in files.items():
full_path = os.path.join(self.test_dir.name, file_path)
with open(full_path, 'w', encoding='utf-8') as f:
f.write(content)
def tearDown(self):
# Clean up the temporary directory
self.test_dir.cleanup()
def test_template_rendering(self):
async def main():
def hello(name):
return 'Hello, {name}'.format(name=escape_html(name))
async def ahello(name):
return 'Hello, {name}'.format(name=escape_html(name))
template_dir = os.path.join(self.test_dir.name, 'templates')
template = Template(template_dir, {'hello': hello, 'ahello': ahello})
values = {
'title': 'My Page <>&; Title',
}
markup = {
'content': '<p>This is the content of the page.</p>'
}
expected_output = """<!DOCTYPE html>
<html lang="en">
<head>
<title>My Page <>&; Title</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
Head
New
{ newer}
</head>
<body>
<p>This is the content of the page.</p>
Hello, Jam<>es
Hello, Jam<>es
{bye}
<footer>
<p>Footer content here</p>
</footer>
{{{ <b>Hi</b> }
{ <p> Hello </p> }}
</body>
</html>"""
rendered = await template.render('main', values, markup, {'name': 'Jam<>es'})
self.assertEqual(rendered.strip(), expected_output.strip())
asyncio.run(main())
if __name__ == '__main__':
unittest.main()
Be the first to comment.
Copyright James Gardner 1996-2020 All Rights Reserved. Admin.