bhftbootcamp / .github

0 stars 0 forks source link

Create a Jinja-like templating engine #1

Open gryumov opened 7 months ago

gryumov commented 7 months ago

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:

[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:

```julia
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 commented 1 week ago

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 commented 15 hours ago

Hi, @femtotrader!

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

What do you think?

femtotrader commented 7 hours ago

Hi @gryumov

Let's remember about the context...

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.

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

Weaknesses

Opportunities

Threats

Staying with OteraEngine.jl

Strengths

Weaknesses

Opportunities

Threats

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.