From 3c0beb7ff68ffd701efcdea72e646dd99f41442c Mon Sep 17 00:00:00 2001 From: kirbylife <kirbylife@localhost.localdomain> Date: Fri, 18 Apr 2025 21:47:23 -0600 Subject: [PATCH] Initial commit --- .gitignore | 10 +++ .python-version | 1 + LICENSE.md | 13 +++ README.md | 12 +++ jinja_lowcost/main.py | 180 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 7 ++ uv.lock | 7 ++ 7 files changed, 230 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 jinja_lowcost/main.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5a47bc6 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + +Copyright (C) 2025 kirbylife <hola@kirbylife.dev> + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document, and changing it is allowed as long +as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef06b98 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Jinja Low-Cost + +This is a toy template engine built using only what the Python standard library includes. + +It was created for three reasons: +- Because at one of my jobs, I needed a dependency-free template engine. +- As an experiment to see how far I could extend Python's default `Formatter`. +- To write an article for [my blog](https://blog.kirbylife.dev/post/creando-un-motor-de-plantillas-ligero-y-malo-en-python-10). + +Inside `jinja_lowcost/main`, at the bottom of the file, you’ll find some usage examples—since I don’t plan to document it properly. + +This code is released under the **WTFPL 2.0** license, so feel free to use it however you like. But please, **do not use it in production**. diff --git a/jinja_lowcost/main.py b/jinja_lowcost/main.py new file mode 100644 index 0000000..8ed907f --- /dev/null +++ b/jinja_lowcost/main.py @@ -0,0 +1,180 @@ +from collections.abc import Callable +from string import Formatter +from typing import Any, Iterable +from enum import Enum +import re +import shlex + +class Instruction(Enum): + REPEAT = "repeat" + JOIN = "join" + IF = "if" + LOWER = "lower" + UPPER = "upper" + STRIP = "strip" + CALL = "call" + NOP = "nop" + +class ConditionalOp(Enum): + NOT = "not" + EQUAL = "equal" + NOTEQUAL = "notequal" + EVEN = "even" + ODD = "odd" + CONTAINS = "contains" + +INSTRUCTION_PATTERN = re.compile("""(?!['|"].*):(?!.*['|"])""") + +class JinjaLowCost(Formatter): + @staticmethod + def parse_instruction(raw_instruction: str) -> tuple[Instruction, list[Any], str | None]: + # 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) + + 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") + + name = next(iter(params), "item") + + processed_elements = [] + for i, item in enumerate(elements): + processed_element = self.format(template, **{name: item, "#": i}) + processed_elements.append(processed_element) + return "".join(processed_elements) + + 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 "" + case Instruction.JOIN: + if not isinstance(value, Iterable): + raise Exception(f"{value} is not iterable") + criteria = next(iter(params), "") + return criteria.join(map(str, value)) + case Instruction.UPPER: + return str(value).upper() + case Instruction.LOWER: + return str(value).lower() + case Instruction.STRIP: + param = next(iter(params), None) + + return str(value).strip(param) + case Instruction.CALL: + if not isinstance(value, Callable): + raise Exception(f"{value} is not callable") + return str(value(*params)) + case Instruction.NOP: + try: + return super().format_field(value, format_spec) + except: + raise Exception(f"Error trying to format '{format_spec}' instruction") + +f = JinjaLowCost() + +res = f.format("{x:join ', '}", x=[1, 2, 3, 4]) +print(res) + +res = f.format("{x:strip '-'}", x="----prueba----") +print(res) +# res = f.format("{a=}", a=10) +# print(res) +res = f.format("{x:repeat n:->{{n}}<-\n}", x=[10, 20, 30]) +print(res) +res = f.format("{x:if contains {y}:estoy dentro}", x=[10, 20, 30], y=10) +print(res) +res = f.format("{x:if contains 42:42 is real}", x=range(10, 50)) +print(res) +res = f.format("{x:join ', '}", x=[10, 20, 30]) +print(res) +res = f.format("{x:upper}", x="Kirbylife") +print(res) +res = f.format("{x:lower}", x="TESTtestTEST") +print(res) +res = f.format("{x.get:call key_a}", x={"key_a": 10, "key_b": 20}) +print(res) +res = f.format("{names:repeat name:{{#:if even:+ {{name}}\n}}{{#:if odd:- {{name:upper}}\n}}}", names=["Pastor", "Suadero", "Tripa"]) +print(res) +template = "{numbers:repeat num:{{num}} }" +print(f.format(template, numbers=[10, 20, 30])) + +res = f.format("{tacos: repeat taco:{{#}}. {{taco:upper}}\n}", tacos=["suadero", "pasTor", "tripa"]) +print(res) + +res = f.format( + "{nums:repeat num:{{num: repeat n:{{{{n:02}}}} }}\n}", + nums=[range(3), range(3,6), range(6, 9), range(9, 12)] +) +print(res) + +res = f.format("{n:repeat:{{#:repeat:* }}\n}", n=7) +print(res) + +template = """ +<ul>{cities:repeat city: + <li style="background-color: {{#:if even:gray}}{{#:if odd:lightgray}}">{{city}}</li>} +</ul> +""" +print(f.format(template, cities=["New York", "New Delhi", "Tokio", "Monterrey"])) + +res = f.format("{name.title:call}", name="JiNjA LoW cOsT") +print(res) + +res = f.format("{exchange.get:call {currency}} {currency}", exchange={"USD": 1, "MXN": 19.7}, currency="MXN") +print(res) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6f1158b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "jinja-lowcost" +version = "0.1.0" +description = "proof-of-concept of a simple template engine written from scratch*" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ffeefa3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,7 @@ +version = 1 +requires-python = ">=3.10" + +[[package]] +name = "jinja-lowcost" +version = "0.1.0" +source = { virtual = "." }