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 = "." }