Title: Creando un motor de plantillas ligero (y malo) en Python

Date: 2024-12-26
Content:

TL;DR: Todo el código de un motor de plantillas muy malo está en mi git.

Hace ya algunos años necesitaba en mi trabajo un motor de plantillas para hacerme la vida mas sencilla al momento de generar unos reportes en HTML que posteriormente serían enviados por e-mail, pero por políticas de la empresa era algo complicado y burocrático que aceptaran alguna dependencias de PyPI, así que preferí aventarme la tarea de hacer mi propio motor de plantillas sin utilizar dependencias, y... salió un monstruo horrible parecido al que voy a recrear en este post.

Para llevar acabo esa tarea decidí extender el motor de plantillas que incluye Python por defecto. el que se utiliza cuando se llama la función format a un string. Dicha funcionalidad se esconde detrás de la clase Formatter dentro del paquete string de la biblioteca estándar.

sintaxis

Vamos comenzar viendo la sintaxis que tendrán los valores y como se van a manipular dentro nuestro motor de plantillas para después irle agregando funcionalidades.

"{variable:operador parametro?:plantilla?}"

La sintaxis tendrá 4 partes:

  1. La variable con la que se va a trabajar.
  2. El operador que se le aplicará.
  3. El parámetro que puede recibir el operador (opcional).
  4. Una plantilla interna que reaccionará al operador (opcional).

La elección de esa sintaxis se ve un poco forzada por como el Formatter funciona por defecto, ya que recordemos que se le pueden aplicar pequeños modificadores al momento de formatear valores dentro de un string, ya sea mostrar un número en hexadecimal o limitar el número de decimales haciendo uso de un mini-lenguaje, así que básicamente lo vamos a extender para poder hacer cosas como estas:

>>> template = "{name:upper}"
>>> print(format(template, name="CódigoComentado"))
CÓDIGOCOMENTADO

>>> template = "{langs:join ', '}"
>>> print(format(template, langs=["Python", "JavaScript", "Rust"]))
Python, JavaScript, Rust

>>> template = "{numbers:repeat num:- {{num}}\n}"
>>> print(format(template, numbers=[10, 20, 30]))
- 10
- 20
- 30

Esqueleto del programa

Para comenzar vamos a importar la clase Formatter que se encuentra dentro del paquete string, esto para posteriormente crear una clase que herede de ella y sobrescribiremos el método format_field que es el encargado de procesar el "mini-lenguaje" del que hablábamos antes.

from typing import Any
from string import Formatter

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        print(f"{value=}")
        print(f"{format_spec=}")
        return super().format_field(value, format_spec)

Sí creamos una instancia de esa clase así como está e intentamos formatear algo de la forma habitual, veremos lo que nos llega por los parámetros.

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        ...                  ^              ^
                             ║              ║
>>> jl = JinjaLowCost()      ║              ║
                     ╔═══════╩╗             ║
>>> jl.format("hola {a:0x}", a=10)          ║
value=10               ╚╩═══════════════════╝
format_spec='0x'       
'hola a'

Como se puede apreciar, el valor de la variable a llega directamente al campo value con todo y su tipo (int en este caso) y la 2da parte de la expresión llega en forma de string al campo format_spec. Esas 2 partes serán los cimientos en lo que construiremos todo lo demás, así que vamos a hacerlo.

Crearemos un Enum con las instrucciones que queremos que tenga nuestro motor de plantillas, esto con el fin de evitar tener strings mágicos por todo el código.

from enum import Enum

class Instruction(Enum):
    LOWER = "lower"
    UPPER = "upper"
    STRIP = "strip"
    JOIN = "join"
    REPEAT = "repeat"
    IF = "if"
    NOP = "nop"

Aparte incluí una opción NOP (No Operation), para indicar cuando a un valor no se le va a aplicar ninguna transformación nuestra, ya sea por que la operación escrita no es correcta o bien por que está utilizando alguna de las que ya vienen por defecto.

Implementación de instrucciones simples

Comenzaremos con las instrucciones que hacen las transformaciones de strings, esto para explicar mas o menos la idea detrás de todo esto ya que son las mas sencillas de implementar.

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        instruction = Instruction(format_spec)
        
        match instruction:
            case Instruction.LOWER:
                return value.lower()
            case Instruction.UPPER:
                return value.upper()
            case _:
                return super().format_field(value, format_spec)

>>> jl = JinjaLowCost()
>>> jl.format("{a:upper} {b:lower}", a="Código", b="Comentado")
'CÓDIGO comentado'

¡¡Funciona!!, ya hemos introducido instrucciones personalizadas al motor de plantillas por defecto.

Ahora bien, este primer prototipo echa aguas por todos lados, ya que para empezar, sí la instrucción que le pasamos no es ninguna de la que definimos en el Enum, nos dará una excepción, así que habría que envolver esa linea en un try-except o hacer una comprobación. Lo segundo es que value puede ser de cualquier tipo de dato, no solo str, así que, o convertimos lo que nos llegue a str o lanzamos una excepción sí recibimos algo que no sea str. En mi caso optaré por ser permisivo y haré una conversión a lo bruto.
Con todo eso implementado queda algo así:

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        try:
            instruction = Instruction(format_spec)
        except ValueError:
            # Si no se encuentra en el Enum, podría ser una instrucción
            # heredada del mini-lenguaje, así que la convertimos en un NOP
            instruction = Instrution.NOP
        
        match instruction:
            case Instruction.LOWER:
                return str(value).lower()
            case Instruction.UPPER:
                return str(value).upper()
            case _:
                return super().format_field(value, format_spec)

Implementación de instrucciones con parámetros

Parsear parámetros siempre es un dolor de cabeza y siempre hay que pensar en los casos que pueden romper nuestro programa. Para hacer la tarea un poco mas sencilla, haré que las instrucciones y los parámetros estén separados por espacios, ya que el split de shlex hace la tarea bastante mas sencilla y justo hace uso del espacio como separador.
Viendo la estructura de la sintaxis, vemos que la variable, el operador junto con sus parámetros y la plantilla están separados por dobles puntos (:).

"{variable:operador parametro?:plantilla?}"

Así que podríamos pensar que con hacer un split(":") bastaría, pero ¿que tal sí queremos utilizar como parámetro un string que contenga justo ":"?, ese método va a separar por el delimitador no importándole sí está dentro de una comillas o no, así que hay que pensar en otra forma de hacerlo, con RegEx por ejemplo (Ya se, intentar resolver un problema con RegEx es igual a tener 2 problemas)...

Vamos a tener de ejemplo este format_spec:

if equal ":": lo que sea

Vemos que los dobles puntos dentro de las comillas deben conservarse, así que diseñamos esta RegEx:

           ╔════ Carácter que usaremos para hacer split
           v
(?!['|"].*):(?!.*['|"])
 ^^          ^^
 ╚╩══════════╩╩═══ Operador de negación

No la voy a explicar del todo (para eso está ChatGPT), pero básicamente tiene un operador de negación a cada lado junto con las comillas simples y dobles para que haga un split por los dobles puntos solo cuando NO tiene ninguna de esas cosas.

Con la parte del operador y sus parámetros por un lado y la plantilla por otro, ya podemos pasarle al split de shlex todo el operador y el solito debería ser capaz de hacer la separación respetando las comillas, quedando algo como:

import re
import shlex

INSTRUCTION_PATTERN = re.compile("""(?!['|"].*):(?!.*['|"])""")

class JinjaLowCost(Formatter):
    @staticmethod
    def parse_instruction(raw_instruction: str) -> tuple[Instruction, list[Any], str]:
        # Separar la instrucción con sus parámetros y la template
        binding = iter(re.split(INSTRUCTION_PATTERN, raw_instruction, maxsplit=1))
        pre_instruction = next(binding, None)

        # Sí no tiene ninguna instrucción, se retorna NOP
        if not pre_instruction:
            return Instruction.NOP, [], ""

        template = next(binding, None)
    
        # Separar la instrucción de sus parámetros respetando las comillas
        instruction, *params = shlex.split(pre_instruction)
    
        try:
            # Retornar todas las partes sí la instrucción está en el Enum
            return Instruction(instruction), params, template
        except ValueError:
            # Si no, retornar NOP
            return Instruction.NOP, [], ""

    def format_field(self, value: Any, format_spec: str) -> Any:
        instruction, params, template = self.parse_instruction(format_spec)
        ...

Con la partes aisladas, ya podemos implementar casi todas las instrucciones restantes. Vamos a comenzar con strip y join para ver como se haría:

from typing import Iterable

class JinjaLowCost(Formatter):
    def parse_instruction(raw_instruction: str) -> tuple[Instruction, list[Any], str]:
        ...

    def format_field(self, value: Any, format_spec: str) -> Any:
        instruction, params, template = self.parse_instruction(format_spec)

        match instruction:
            ...
            case Instruction.STRIP:
                param = next(iter(params), None)

                return str(value).strip(param)
            case Instruction.JOIN:
                if not isinstance(value, Iterable):
                    raise Exception(f"'{value}' is not iterable")
                param = next(iter(params), "")

                param.join(value)
            ...

En este caso sí le puse una validación de tipo a la instrucción de join, ya que sí intentamos hacerlo con cualquier tipo de dato, daría un error mas complejo de depurar.

Lo probamos y vemos que funciona bien:

>>> jl.format("{x:join ', '}", x=[1, 2, 3, 4])
'1, 2, 3, 4'

>>> f.format("{x:strip '-'}", x="----prueba----")
'prueba'

Implementación de instrucciones con template

Ya casi terminamos, solo nos faltan las instrucciones que utilizan parámetros y además utilizan la template, que son el if y el repeat. Comenzaremos con este último, ya que es el más sencillo de implementar.

repeat

Antes de empezar tengo que comentar una característica algo curiosa del Formatter, y es que, como ya sabemos, sí envolvemos una variable en un solo par de corchetes, esta será renderizada.

>>> "{a}".format(a=10)
"10"

Pero sí la envolvemos en 2 pares, esta no será renderizada, sino que le quitará un juego de corchetes:

>>> "{{a}}".format(a=10)
"{a}"

Lo cual es convenientemente útil para poder marcar el scope de las variables, ya que a ese format le podemos aplicar otro y ahora si debería renderizar la variable:

>>> "{{a}}".format().format(a=10)
"10"

Al repeat le vamos poder pasar un parámetro opcional y será el nombre de la nueva variable que crearemos para poder utilizar dentro de su plantilla, y en caso de que no le pasemos ningún parámetro el nombre por defecto será "item". Vamos a implementar todo eso.

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        instruction, params, template = self.parse_instruction(format_spec)

        match instruction:
            ...
            case Instruction.REPEAT:
                if not isinstance(value, Iterable):
                    raise Exception(f"'{value}' is not iterable")
                if template is None:
                    raise Exception("A template is needed")
                
                name = next(iter(params), "item")
                return "".join(self.format(template, **{name: item}) for item in value)
            ...

Ya así como lo tenemos debería funcionar:

>>> jl.format("{x:repeat n:->{{n}}<-\n}", x=[10, 20, 30])
->10<-
->20<-
->30<-

Pero tiene varias falencias, como por ejemplo que no puede iterar sobre los elementos de un diccionario directamente o tampoco podemos acceder al índice de cada elemento, y también me gustaría que al pasarle un int tenga un comportamiento similar a a un rango, así que vamos a implementar todas esas opciones:

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        match instruction:
            ...
            case Instruction.REPEAT:
                if isinstance(value, dict):
                    elements = value.items()
                elif isinstance(value, int):
                    elements = range(value)
                elif isinstance(value, Iterable):
                    elements = value
                else:
                    raise Exception(f"'{value}' is not iterable")
                if template is None:
                    raise Exception("A template is needed")

                var_name = next(iter(params), "item")

                processed_elements = []
                for i, item in enumerate(elements):
                    processed_element = self.format(rest, **{var_name: item, "#": i})
                    processed_elements.append(processed_element)
                return "".join(processed_elements)
            ...

Para poder acceder al índice me pareció buena idea utilizar el carácter "#", así que ya podemos hacer cosas como estas:

>>> jl.format("{tacos: repeat taco:{{#}}. {{taco:upper}}\n}", tacos=["suadero", "pasTor", "tripa"])
0. SUADERO
1. PASTOR
2. TRIPA

Y podemos hasta tener loops anidados... eso si, la legibilidad de la plantilla se comienza a ver comprometida:

>>> jl.format(
        "{nums:repeat num:{{num: repeat n:{{{{n:02}}}} }}\n}",
        nums=[range(3), range(3,6), range(6, 9), range(9, 12)]
    )
00 01 02
03 04 05
06 07 08
09 10 11

>>> jl.format("{n:repeat:{{#:repeat:* }}\n}", n=7)

*
* *
* * *
* * * *
* * * * *
* * * * * *

Final Boss: if

Ya casi lo tenemos terminado, solo nos falta el if y oficialmente hemos terminado de hacer nuestro propio motor de plantillas, vamos a ello.
Vamos a comenzar declarando otro Enum, pero esta vez con todas las posibles operaciones que puede ejecutar nuestro if:

class ConditionalOp(Enum):
    NOT = "not"
    EQUAL = "equal"
    NOTEQUAL = "notequal"
    EVEN = "even"
    ODD = "odd"
    CONTAINS = "contains"

Omití el "mayor que", "menor igual que", etc... para hacer la explicación mas sencilla, pero puede ser un buen ejercicio para que lo intentes implementar por tu cuenta.

Vamos primero a implementar únicamente el "igual" para que se vea mejor la lógica detrás del if y posteriormente implementaré el resto:

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        match instruction:
            ...
            case Instruction.IF:
                try:
                    conditional = ConditionalOp(next(iter_params))
                except ValueError:
                    raise Exception(f"'{params[0]}' is not a valid if conditional")
                criteria = next(iter_params, None)
                match conditional:
                    case ConditionalOp.EQUAL:
                        if criteria is None:
                            raise Exception("Equal operator requires a parameter")
                        func = lambda: value == type(value)(criteria)
            ...

Básicamente se repite la lógica de verificar sí el operador que se colocó pertenece al Enum con un try-except, después intentamos sacar un criterio el cual utilizaremos saber con que comparar el valor que recibimos.
La parte quizás mas hacky es en donde declaramos una variable a la cual le asignamos una lambda y hacemos una conversión de tipos algo rara, pero es que realmente el criterio es un string y siempre va a ser un string, en cambio value puede ser de cualquier tipo de datos que no sabemos.
Entonces, debemos tomar el tipo de dato de value y le pasamos el string del criterio para que lo convierta, esto casi siempre funciona jaja.
Ahora ya tenemos una función que podemos llamar y nos dirá si los valores son iguales y deberemos generar una función así para cada operación del if.

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        match instruction:
            ...
            case Instruction.IF:
                try:
                    conditional = ConditionalOp(next(iter_params))
                except ValueError:
                    raise Exception(f"'{params[0]}' is not a valid if conditional")
                criteria = next(iter_params, None)
                match conditional:
                    case ConditionalOp.EQUAL:
                        if criteria is None:
                            raise Exception("Equal operator requires a parameter")
                        func = lambda: value == type(value)(criteria)
                    case ConditionalOp.NOTEQUAL:
                        if criteria is None:
                            raise Exception("Equal operator requires a parameter")
                        func = lambda: value != type(value)(criteria)
                    case ConditionalOp.CONTAINS:
                        if criteria is None:
                        raise Exception("Contains operator requires a parameter")
                        
                        func = lambda: value and type(value[0])(criteria) in value
                    case ConditionalOp.EVEN:
                            func = lambda: value % 2 == 0
                        case ConditionalOp.ODD:
                            func = lambda: value % 2 != 0
                        case ConditionalOp.NOT:
                            func = lambda: not value
            ...

El "contains" tiene una limitación y es que solo va a tomar el tipo de datos del primer elemento, así que sí le pasas una lista con números, booleanos, texto, etc... solo va a intentar convertir el valor al primero elemento de la lista.

Y listo, ya solo falta colocar hasta abajo la llamada a la función y si es True, devolver el template y sí es False, retornar un string vacío.

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        match instruction:
            ...
            case Instruction.IF:
                try:
                    conditional = ConditionalOp(next(iter_params))
                except ValueError:
                    raise Exception(f"'{params[0]}' is not a valid if conditional")
                case Instruction.IF:
                    ...
            return template if func() else ""

Y como ultima cosita, quiero que el if se pueda utilizar así solo, para que evalúe el truthy/falsy del propio value. Ese caso lo podemos resolver hasta arriba, sin necesidad de crear otra función:

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        match instruction:
            ...
            case Instruction.IF:
                if not params:
                    return template if value else ""

                iter_params = iter(params)
                try:
                    conditional = ConditionalOp(next(iter_params))
                except ValueError:
                    raise Exception(f"'{params[0]}' is not a valid if conditional")

                criteria = next(iter_params, None)
                match conditional:
                    case ConditionalOp.EQUAL:
                        if criteria is None:
                            raise Exception("Equal operator requires a parameter")
                        func = lambda: value == type(value)(criteria)
                    case ConditionalOp.NOTEQUAL:
                        if criteria is None:
                            raise Exception("NotEqual operator requires a parameter")
                        func = lambda: value != type(value)(criteria)
                    case ConditionalOp.CONTAINS:
                        if criteria is None:
                            raise Exception("Contains operator requires a parameter")
                        func = lambda: value and type(value[0])(criteria) in value
                    case ConditionalOp.EVEN:
                        func = lambda: value % 2 == 0
                    case ConditionalOp.ODD:
                        func = lambda: value % 2 != 0
                    case ConditionalOp.NOT:
                        func = lambda: not value
                return template if func() else ""

Ahora si lo podemos ver en acción:

>>> template = """
<ul>{cities:repeat city:
  <li style="background-color: {{#:if even:gray}}{{#if odd:lightgray}}">{{city}}</li>}
</ul>
"""
>>> jl.format(template, cities=["New York", "New Delhi", "Tokio", "Monterrey"])
<ul>
  <li style="background-color: gray">New York</li>
  <li style="background-color: lightgray">New Delhi</li>
  <li style="background-color: gray">Tokio</li>
  <li style="background-color: lightgray">Monterrey</li>
</ul>

Y listo, ya hemos terminado :).

Sí quieren ver el código completo, lo puedes encontrar en mi git.
Como conclusión les puedo decir que, aunque podamos hacer las cosas por nuestra cuenta, no significa que las debamos hacer... así que háganme un favor a mi y a ustedes y no utilicen este monstruo de código en producción, utilicen jinja2 y ya.

Bonus

En el código de Git hay una instrucción extra que es "call", por que desde la plantilla no podemos mandar llamar a una función, así que si queremos acceder a todos métodos de un string (title, center, etc...) tendríamos que implementar una a una, lo cual puede ser algo molesto, así que podemos incluir una opción mas al Enum e implementarlo:

class Instruction(Enum):
    ...
    NOP = "nop"

class JinjaLowCost(Formatter):
    def format_field(self, value: Any, format_spec: str) -> Any:
        instruction, params, template = self.parse_instruction(format_spec)

        match instruction:
            ...
            case Instruction.CALL:
                if not isinstance(value, Callable):
                    raise Exception(f"{value} is not callable")
                return str(value(*params))
            ...

Y listo, ya podemos hacer:

>>> jl.format("{name.title:call}", name="JiNjA LoW cOsT")
"Jinja Low Cost"

>>> jl.format("{exchange.get:call {currency}} {currency}", exchange={"USD": 1, "MXN": 19.7}, currency="MXN")
"19.7 MXN"

Y ya, ahora sí eso es todo, adiós.