Home Blog CV Projects Patterns Notes Book Colophon Search

Python Template

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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;')


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 &lt;&gt;&amp;; 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&lt;&gt;es
  Hello, Jam&lt;&gt;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()

Comments

Be the first to comment.

Add Comment





Copyright James Gardner 1996-2020 All Rights Reserved. Admin.