Quelklef / unplate

A minimal Python templating engine for people who don't like templating engines.
MIT License
2 stars 0 forks source link

Support decorators #4

Open Quelklef opened 4 years ago

Quelklef commented 4 years ago

Allow for

def italicize(text):
  return f"<em>{ text }</em>"

@italicize
[unplate.begin(my_template)] @ r"""
I may be {{ adj1 }},
but at least I'm not {{ adj2 }}!
""" [unplate.end]

The difficulty in doing this is preserving line numbers in the compiled output. Otherwise, the above could compile into something like

@italicize
@unplate.call
def my_template(my_template=[]):
  my_template.extend(["I may be ", str(adj1), ",\n"])
  my_template.extend(["but at least I'm not ", str(adj2), "!\n"])
  return ''.join(my_template)

where unplate.call simply calls the function it wraps. But this is one line too long--the @unplate.call line will bump all lines afterwards down one.

Quelklef commented 4 years ago

I don't think a perfect solution is possible.

This is because decorators can only appear before function and class definitions, which will always evaluate to a function and a class, respectively. On the contrary, our goal here is for templates to evaluate to a string.

I see a few possible options to support decorators, but none of them are pretty.

Option one: don't evaluate to a string

Option one is to accept that we won't be able to evaluate to a string. Instead, compile down into class syntax and evaluate to a proxy to a string. Something like this:

@italicize
[unplate.begin(my_template)] @ r"""
I may be {{ adj1 }},
but at least I'm not {{ adj2 }}!
""" [unplate.end]

# compiles to

@italicize
class my_template(unplate.Template): lines = []; \
  lines.extend(["I may be ", str(adj1), ",\n"]); \
  lines.extend(["but at least I'm not ", str(adj2), "!\n"]); \
  text = ''.join(lines)

# where unplate has defined

class Template(str):
  def __init__(self):
    self.text = type(self).text

  def __str__(self):
    return self.text

  def __getattr__(self, name):
    return getattr(self.text, name)

Pros:

Cons:

Option two: accept ugly syntax

Alternatively, we could compile templates down into functions and require that they be lead with an unplate.decorated decorator, like so:

@italicize
@unplate.decorated
[unplate.begin(my_template)] @ r"""
I may be {{ adj1 }},
but at least I'm not {{ adj2 }}!
""" [unplate.end]

# compiles to

@italicize
@unplate.decorated
def my_template(lines=[]):
  lines.extend(["I may be ", str(adj1), ",\n"])
  lines.extend(["but at least I'm not ", str(adj2), "!\n"])
  return ''.join(lines)

# where unplate has defined

def decorated(func):
  return func()

When the template builder is undecorated, it would compile down as usual--not into a function.

Pros:

Cons:

Option three: dynamically modify exception line numbers

In a similar vein to https://github.com/Quelklef/unplate/issues/2, move the responsibility of executing compiled unplate code to unplate, and then catch errors and dynamically modify the line numbers (and perhaps even the column numbers!) of these errors to match the pre-compiled source.

@italicize
[unplate.begin(my_template)] @ r"""
I may be {{ adj1 }},
but at least I'm not {{ adj2 }}!
""" [unplate.end]

# compiles to

@italicize
@unplate.decorated
def my_template(lines=[]):
  lines.extend(["I may be ", str(adj1), ",\n"])
  lines.extend(["but at least I'm not ", str(adj2), "!\n"])
  return ''.join(lines)

# where unplate has defined

def decorated(func):
  return func()

Pros:

Cons:

Quelklef commented 4 years ago

I feel that option three is reasonable. The fact that Unplate compiles back into Python internally should be an implementation detail. The top-level API should be changed from

exec(unplate.compile(__file__, globals(), locals())

to

unplate.main(__file__, globals(), locals())

or something similar, and then option 3 should be followed.