Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create a Jinja-like templating engine #1

Open
4 tasks
gryumov opened this issue Apr 17, 2024 · 3 comments
Open
4 tasks

Create a Jinja-like templating engine #1

gryumov opened this issue Apr 17, 2024 · 3 comments

Comments

@gryumov
Copy link
Member

gryumov commented Apr 17, 2024

Create a Jinja-like templating engine

You need to create a Julia package that implements functionality of Jinja templating engine. You can either do a pure Julia implementation from scratch or wrap an existing C library.

In case, you want to implement a C wrapper, make sure to change the repo name according to Julia package naming conventions.

Tasks

The package must support the following types and expressions:

Requirements

Note that templating engines are usually file-agnostic and work with any txt-like extension. For the purposes of this task, the package should support file formats that do not conflict with {{ % ... % }} syntax. Having all of the key-symbols specifiable and not hard-coded would be a boon.

API

User interface (API) of the package should adhere to the following:

Creation of base NinjaTemplate object:

using Ninja

# Passing a template string directly 
template = NinjaTemplate(
    """
        Hello, {{ name }}! 
        ...
    """
)

# Using a string macro (@str_ninja_template)
template = ninja_template"""
    Hello, {{ name }}! 
    ...
"""

# Passing a byte array from a file
template = NinjaTemplate(
    read("~/template.xml")
)

Next, implement a ninja_render method to fill the template. The output of the function should be a String:

# It should accept structs
struct CustomType
    ...
end

custom_type = CustomType(...)

julia> ninja_render(template, custom_type)

# And Dicts
some_dict = Dict{String,Any}(...)

julia> ninja_render(template, some_dict)

Syntax

Syntax should be similar to Jinja:

Variables

Template variables should look similar to:

{{ foo }}
{{ foo.bar }}
{{ foo["bar"] }}

After the ninja_render call every case of {{ ... }} variable must be replaced with an according value. If the value is missing then it must be left blank.

Example

Julia code:

struct Book
    title::String
    year::Int64
    price::Float64
end

my_book = Book(
    "Advanced Julia Programming",
    2024,
    49.99,
)

template = NinjaTemplate(
    """
    <book>
    <title>{{ title }}</title>
    <authors>
        <author lang="en">John Doe</author>
        <author lang="es">Juan Pérez</author>
    </authors>
    <year>{{ year }}</year>
    <price>{{ price }}</price>
    </book>
    """
)

ninja_render(template, my_book)

Expected output:

<book id="bk101">
    <title>Advanced Julia Programming</title>
    <authors>
        <author lang="en">John Doe</author>
        <author lang="es">Juan Pérez</author>
    </authors>
    <year>2024</year>
    <price>49.99</price>
</book>

If

Conditional if statement should look similar to:

{% if <statement_1> %}
    line_1
{% elif <statement_2> %}
    line_2
{% else %}
    line_3
{% endif %}

The line should be filled according to the result of if-elif-else statement. If the value does not match any of the conditions, the line must be left blank.

Example

Julia code:

cloud_server = Dict{String,Any}("status" => 1)

template = NinjaTemplate(
    """
    name: cloud_server
    {% if status == 0 %}
    status: offline
    {% elif status == 1 %}
    status: online
    {% else %}
    status: NA
    {% endif %}
    """
)

ninja_render(template, cloud_server)

Expected output:

name: cloud_server
status: online

For

Loop operator for should look similar to:

{% for <var> in <iterable> %}
    ...
{% endfor %}

or

{% for (<var1>, <var2>, ...) in <iterable> %}
    ...
{% endfor %}

For every <var> in <iterable> collection the inner block should be executed. An empty collection must be blank.

Example

Julia code:

struct Student
    id::Int64
    name::String
    grade::Float64
end

struct School
    students::Vector{Student}
end

school = School([
    Student(1, "Fred", 78.2),
    Student(2, "Benny", 82.0),
])

template = NinjaTemplate(
    """
    "id","name","grade"
    {% for student in students %}
    {{ student.id }},{{ student.name }},{{ student.grade }}
    {% endfor %}
    """
)

ninja_render(template, school)

Expected output:

"id","name","grade"
1,"Fred",78.2
2,"Benny",82.0

Include

The include statement should be similar to:

{% include "<file_path>" %}

Here include copies contents of another file. If the file is a template, the copied statements must be executed, too.

Example

Template files Binance_candle.json, Coinbase_candle.json and Candle_data.json:

{
    "openPrice": {{ o }},
    "highPrice": {{ h }},
    "lowPrice": {{ l }},
    "closePrice": {{ c }},
    "volume": {{ v }}
}
{
    "open": {{ o }},
    "high": {{ h }},
    "low": {{ l }},
    "close": {{ c }},
    "volume": {{ v }}
}
{
    "candle":
    {% if type == "Binance" %}
    {% include "Binance_candle.json" %}
    {% elif type == "Coinbase" %}
    {% include "Coinbase_candle.json" %}
    {% endif %}
}

Julia code:

struct Candle
    type::String
    o::Float64
    h::Float64
    l::Float64
    c::Float64
    v::Float64
end

candle = Candle(
    "Binance",
    12.4,
    45.0,
    10.7,
    19.2,
    3456.7,
)

template = NinjaTemplate(read("~/Candle_data.json", String))
ninja_render(template, candle)

Expected behavior:

{
    "candle":
    {
        "openPrice": 12.4,
        "highPrice": 45.0,
        "lowPrice": 10.7,
        "closePrice": 19.2,
        "volume": 3456.7
    }
}

Complex example

The following example should help you debug your code.

Julia uses Compat.toml and Project.toml to track dependencies. They can be formalised as the following templates:

  • Compat.toml
[compat]
{% for (name, version) in compat %}
{{ name }} = {{ version }}
{% endfor %}
  • Project.toml
name = {{ name }}
uuid = {{ uuid }}
version = {{ version }}
{% if !isempty(deps) %}

[deps]
{% for (dep, uuid) in deps %}
{{ dep }} = {{ uuid }}
{% endfor %}

{% include "Compat.toml" %}
{% endif %}

Then we should be able to fill the Project.toml template with the following code snippet:

using Ninja 

# Initialise the template
template = NinjaTemplate(read("~/Project.toml", String))

# Then define a structure that will be used to fill the template
using UUIDs

struct Project
    name::String
    uuid::UUID
    version::VersionNumber
    deps::Vector{Pair{String,UUID}}
    compat::Vector{Pair{String,VersionNumber}}
end

# Create the object
cryptoapis = Project(
    "CryptoAPIs",
    UUID("5e3d4798-c815-4641-85e1-deed530626d3"),
    v"0.13.0",
    [
        "Base64" => UUID("2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"),
        "Dates" => UUID("ade2ca70-3891-5945-98fb-dc099432e06a"),
        "JSONWebTokens" => UUID("9b8beb19-0777-58c6-920b-28f749fee4d3"),
        "NanoDates" => UUID("46f1a544-deae-4307-8689-c12aa3c955c6"),
    ],
    [
        "JSONWebTokens" => v"1.1.1",
        "NanoDates" => v"0.3.0",
    ],
)

# Render the template
ninja_render(template, cryptoapis)

Expected output:

name = "CryptoAPIs"
uuid = "5e3d4798-c815-4641-85e1-deed530626d3"
version = "0.13.0"

[deps]
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
JSONWebTokens = "9b8beb19-0777-58c6-920b-28f749fee4d3"
NanoDates = "46f1a544-deae-4307-8689-c12aa3c955c6"

[compat]
JSONWebTokens = "1.1.1"
NanoDates = "0.3.0"
@femtotrader
Copy link

femtotrader commented Nov 9, 2024

You should probably look at https://mommawatasu.github.io/OteraEngine.jl/

using OteraEngine


function api_example_template_creation_v1()
    template = Template(
        """
            Hello, {{ name }} from {{ from }}! 
            ...
        """, path=false
    )

    return template
end

function api_example_rendering_with_dict()
    template = api_example_template_creation_v1()
    some_dict = Dict("name" => "BHFT", "from" => "FemtoTrader")
    println(template(init = some_dict))
end

struct OpenSourceContributor
    name::String
    from::String
    email::String
end

function api_example_rendering_with_struct()
    template = api_example_template_creation_v1()
    contributor = OpenSourceContributor("BHFT", "FemtoTrader", "AtGmailDotCom")
    d_contributor = Dict(String(k) => v for (k,v) in zip(fieldnames(typeof(contributor)), getfield.(Ref(contributor), fieldnames(typeof(contributor)))))
    println(template(init = d_contributor))
end

struct Book
    title::String
    year::Int64
    price::Float64
end

function api_example_rendering_with_variables()    
    my_book = Book(
        "Advanced Julia Programming",
        2024,
        49.99,
    )
    
    template = Template(
        """
        <book>
        <title>{{ title }}</title>
        <authors>
            <author lang="en">John Doe</author>
            <author lang="es">Juan Pérez</author>
        </authors>
        <year>{{ year }}</year>
        <price>{{ price }}</price>
        </book>
        """, path=false
    )

    d_book = Dict(String(k) => v for (k,v) in zip(fieldnames(typeof(my_book)), getfield.(Ref(my_book), fieldnames(typeof(my_book)))))
    println(template(init = d_book))
end

function api_example_rendering_with_if()
    cloud_server = Dict{String,Any}("status" => 1)

    template = Template(
        """
        name: cloud_server
        {% if (status == 0) %}
        status: offline
        {% elseif (status == 1) %}
        status: online
        {% else %}
        status: NA
        {% end %}
        """, path=false
    )

    println(template(init = cloud_server))
end


struct Student
    id::Int64
    name::String
    grade::Float64
end

struct School
    students::Vector{Student}
end

function api_example_rendering_with_for()
    school = School([
        Student(1, "Fred", 78.2),
        Student(2, "Benny", 82.0),
    ])

    template = Template(
        """
        "id","name","grade"
        {% for student in students %}
        {{ student.id }},{{ student.name }},{{ student.grade }}
        {% end %}
        """, path=false
    )
    d_school = Dict("students" => school.students)
    println(template(init = d_school))
    # bug: the output is not (exactly) as expected - there are extra newlines
    # it should be fixed upstream
    # https://github.com/MommaWatasu/OteraEngine.jl/issues/39
end


struct Candle
    type::String
    o::Float64
    h::Float64
    l::Float64
    c::Float64
    v::Float64
end

function api_example_rendering_with_include()
    candle = Candle(
        "Binance",
        12.4,
        45.0,
        10.7,
        19.2,
        3456.7,
    )
    
    template = Template(joinpath(homedir(), "Candle_data.json"))
    """
    > cat Candle_data.json
    {
        "candle":
        {% if type == "Binance" %}
        {% include "Binance_candle.json" %}
        {% elseif type == "Coinbase" %}
        {% include "Coinbase_candle.json" %}
        {% end %}
    }
    """
    d_candle = Dict(String(k) => v for (k,v) in zip(fieldnames(typeof(candle)), getfield.(Ref(candle), fieldnames(typeof(candle)))))
    println(template(init = d_candle))
end

using UUIDs

struct Project
    name::String
    uuid::UUID
    version::VersionNumber
    deps::Vector{Pair{String,UUID}}
    compat::Vector{Pair{String,VersionNumber}}
end

function api_example_rendering_complex_example()
    # Initialise the template
    template = Template(joinpath(homedir(), "Project.toml"))

    """
    > cat Project.toml
    name = "{{ name }}"
    uuid = "{{ uuid }}"
    version = "{{ version }}"
    {% if !isempty(deps) %}

    [deps]
    {% for (dep, uuid) in deps %}
    {{ dep }} = "{{ uuid }}"
    {% end %}

    {% include "Compat.toml" %}
    {% end %}

    > cat Compat.toml
    [compat]
    {% for (name, version) in compat %}
    {{ name }} = "{{ version }}"
    {% end %}
    """
            
    # Create the object
    cryptoapis = Project(
        "CryptoAPIs",
        UUID("5e3d4798-c815-4641-85e1-deed530626d3"),
        v"0.13.0",
        [
            "Base64" => UUID("2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"),
            "Dates" => UUID("ade2ca70-3891-5945-98fb-dc099432e06a"),
            "JSONWebTokens" => UUID("9b8beb19-0777-58c6-920b-28f749fee4d3"),
            "NanoDates" => UUID("46f1a544-deae-4307-8689-c12aa3c955c6"),
        ],
        [
            "JSONWebTokens" => v"1.1.1",
            "NanoDates" => v"0.3.0",
        ],
    )

    # Create the dictionary
    d_cryptoapis = Dict(String(k) => v for (k,v) in zip(fieldnames(typeof(cryptoapis)), getfield.(Ref(cryptoapis), fieldnames(typeof(cryptoapis)))))

    # Render the template
    println(template(init = d_cryptoapis))

end


api_example_rendering_with_dict()

api_example_rendering_with_struct()

api_example_rendering_with_variables()

api_example_rendering_with_if()

api_example_rendering_with_for()

api_example_rendering_with_include()

api_example_rendering_complex_example()

outputs

> julia .\1_basic.jl
Hello, BHFT from FemtoTrader!
    ...

Hello, BHFT from FemtoTrader!
    ...

<book>
<title>Advanced Julia Programming</title>
<authors>
    <author lang="en">John Doe</author>
    <author lang="es">Juan Pérez</author>
</authors>
<year>2024</year>
<price>49.99</price>
</book>

name: cloud_server

status: online


"id","name","grade"

1,Fred,78.2

2,Benny,82.0


{
    "candle":

    {
    "openPrice": 12.4,
    "highPrice": 45.0,
    "lowPrice": 10.7,
    "closePrice": 19.2,
    "volume": 3456.7
}

}

name = "CryptoAPIs"
uuid = "5e3d4798-c815-4641-85e1-deed530626d3"
version = "0.13.0"


[deps]

Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"

Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"

JSONWebTokens = "9b8beb19-0777-58c6-920b-28f749fee4d3"

NanoDates = "46f1a544-deae-4307-8689-c12aa3c955c6"


[compat]

JSONWebTokens = "1.1.1"

NanoDates = "0.3.0"

@gryumov
Copy link
Member Author

gryumov commented Nov 22, 2024

Hi, @femtotrader!

How about wrapping Jinja2Cpp_jll.jl? This package seems promising for implementing templates using Jinja2 in Julia.

What do you think?

@femtotrader
Copy link

femtotrader commented Nov 22, 2024

Hi @gryumov

Let's remember about the context...

  • Jinja2 is a Python templating engine.
  • Jinja2Cpp is an "almost full-conformance template engine implementation" (a C++ implementation of the Jinja2 Python template engine.)

In this issue you asked (with an example) for a Jinka-like templating engine for Julia.

My answer will be biased as I provided you code showing that OteraEngine.jl a pure Julia package can do what was requested in your examples (with very small template code adaptation).

OteraEngine is according its GH repo "Very very small! There are no dependency. Jinja-like syntax. Easy to use.".

I don't know exactly what features are missing in OteraEngine.jl for your use cases. So I'd first try, if I where you, to answer this question.

You should weight the pros and cons of wrapping Jinja2Cpp.

  • Pros of wrapping Jinja2Cpp_jll.jl:
  1. Jinja2 is a battle-tested template engine used in major projects worldwide
  2. Large existing ecosystem of Jinja2 templates could be reused
  3. Familiar syntax would make it easier for Python developers to transition to Julia (but Otera syntax is quite close to Jinja2 syntax)
  4. The C++ implementation could potentially offer better performance for complex templates (but Julia is quite efficient too)
  5. Would enable seamless integration in mixed Python/Julia workflows
    (as same exact template could be used in a mixed environment)
  • Cons of OteraEngine.jl:
  1. Relatively new and less battle-tested in production
  2. Smaller community and fewer examples/resources available (I only see 2 active contributors... but is Jinja2Cpp bigger?)
  3. Teams would need to rewrite existing Jinja2 templates (but that's not a so difficult task to achieve)
  4. Custom syntax requires learning curve even for experienced template users
  5. May lack some advanced features present in Jinja2
  • Cons of wrapping Jinja2Cpp:
  1. Adds a C++ dependency which increases complexity and potential maintenance burden
  2. Would require writing and maintaining JLL bindings
  3. Cross-platform compilation challenges when dealing with C++ libraries
  4. Performance overhead from crossing language boundaries
  5. Any bugs would be harder to fix since they could be in either the C++ or Julia layer
  • Advantages of staying with OteraEngine.jl:
  1. Pure Julia solution - easier to maintain and debug
  2. Already provides the core templating functionality needed
  3. Better integration with Julia's type system
  4. No external dependencies to manage
  5. Likely better performance due to native Julia implementation

A more formal approach through a SWOT analysis could probably be considered. I asked to a LLM for such an analysis... Here are results... (but maybe it should be "manually" improved...)

Wrapping Jinja2Cpp.jl

Strengths

  • Leverages mature, battle-tested Jinja2 template engine
  • Large existing ecosystem of templates and resources
  • Familiar syntax for Python developers
  • Rich feature set from years of development
  • Strong community knowledge base
  • Proven in large-scale production environments

Weaknesses

  • Adds C++ dependency complexity
  • Requires maintaining JLL bindings
  • Cross-platform compilation challenges
  • Performance overhead from language boundary crossing
  • More complex debugging across language barriers
  • Increased package size due to C++ dependencies

Opportunities

  • Bridge Python and Julia ecosystems
  • Attract Python developers to Julia
  • Reuse existing Jinja2 templates
  • Share templates across multilingual projects
  • Potential for cross-pollination of features
  • Could become the de-facto templating standard in Julia

Threats

  • C++ dependency could break with updates
  • Security vulnerabilities in C++ layer
  • Maintenance burden might become unsustainable
  • Performance issues might be hard to resolve
  • Community might prefer pure Julia solutions
  • Future Jinja2 versions might introduce incompatibilities

Staying with OteraEngine.jl

Strengths

  • Pure Julia implementation
  • Native integration with Julia type system
  • No external dependencies
  • Easier to maintain and debug
  • Better performance potential
  • Smaller package footprint

Weaknesses

  • Less mature than Jinja2
  • Smaller ecosystem
  • Limited production validation
  • Requires learning new syntax
  • Fewer resources and examples
  • May lack advanced features

Opportunities

  • Build Julia-native templating ecosystem
  • Optimize for Julia-specific use cases
  • Innovate beyond Jinja2 constraints
  • Create Julia-specific best practices
  • Grow community around pure Julia solution
  • Potential for better performance

Threats

  • May not attract Python developers
  • Could fragment templating ecosystem
  • Might struggle to match Jinja2 features
  • Risk of project abandonment
  • Competition from other Julia templates
  • Could be overshadowed by wrapped solutions

Hope that helps.

Femto

PS : What LLM forgets here is that relying on Jinja2Cpp is not the same as relying on Jinja2.

Jinja2Cpp community is not Jinja2 community
on the other side Python performances are not C++ performances !

PS2 : What is also important to consider is that building a wrapper for such a library can have educational sense. It can help to better understand how a wrapper can be done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants