500 lines or less 是一系列非常经典而相对短小的python文章,每一章代码不超过500行,却实现了一些强大的功能,由业内大牛执笔,有很大的学习价值。适合新手了解基本概念,也适合用来python进阶。
本篇原文
源码
其他的一些开源的翻译文章
写引擎
既然我们已经懂得了这个引擎要做什么,让我们来实现它。
The Templite class
模板引擎的核心是Templite类。(它是一个模板,但是它是精简版的)
这个类具有一个小的接口。你可以利用模板中的文本构建一个Templite对象,然后你可以使用它的render方法来渲染一个特定的上下文(数据的字典)到模板中。
# Make a Templite object.
templite = Templite('''
<h1>Hello {{name|upper}}!</h1>
{% for topic in topics %}
<p>You are interested in {{topic}}.</p>
{% endfor %}
''',
{'upper': str.upper},
)
# Later, use it to render some data.
text = templite.render({
'name': "Ned",
'topics': ['Python', 'Geometry', 'Juggling'],
})
我们将模板中的文本在对象创建时传递给它,这样我们就能只做一次编译步骤,然后多次调用render
函数来重用编译结果。
构造函数也接受一个字典来作为初始的上下文。这些数据被存储在Templite对象里,并且之后当模板被渲染时可以获取。这个位置适合于一些我们希望能随时获取的函数和常量,比如之前例子中的upper
函数。
在我们讨论Templite的实现之前,我们需要先定义一个助手:CodeBuilder。
CodeBuilder
我们的模板引擎的主要工作是解析模板并产生必要的python代码。为了帮助产生python代码,我们创建了一个CodeBuiler类,当我们构建python代码时它为我们处理簿记。它增加代码行,管理缩进,最终给我们编译好的python代码。
一个CodeBuilder对象对一整块python代码负责。对于我们的模板引擎,python块始终是一个完整的函数定义。但是CodeBuilder类并不假设它只是一个函数,这让CodeBuilder更通用,并且与余下的模板引擎代码的耦合度低。
正如我们所看到的,我们也使用嵌套的CodeBuilders 来让把代码放在函数的开始变得可能,即使我们可能直到完成才知道它到底做了什么。
一个CodeBuilder对象保存一个字符串列表,该列表将被组合到最终的python代码。它唯一需要的其它状态是当前的缩进级别:
class CodeBuilder(object):
"""Build source code conveniently."""
def __init__(self, indent=0):
self.code = []
self.indent_level = indent
CodeBuilder并没有做太多。add_line
添加了一行新代码,它会自动缩进到当前缩进级别,并提供一个换行符。
def add_line(self, line):
"""Add a line of source to the code.
Indentation and newline will be added for you, don't provide them.
"""
self.code.extend([" " * self.indent_level, line, "\n"])
indent
和dedent
提高和降低当前的缩进级别:
INDENT_STEP = 4 # PEP8 says so!
def indent(self):
"""Increase the current indent for following lines."""
self.indent_level += self.INDENT_STEP
def dedent(self):
"""Decrease the current indent for following lines."""
self.indent_level -= self.INDENT_STEP
add_section
被另一个CodeBuilder对象管理。
这让我们在代码中保留一个参考位置,之后在那添加文本。self.code
列表主要是一列字符串,但是也保存了对CodeBuilder片段的引用。
def add_section(self):
"""Add a section, a sub-CodeBuilder."""
section = CodeBuilder(self.indent_level)
self.code.append(section)
return section
__str__
产生单个字符串,只是简单地把self.code
中的所有字符串组合在一起。注意,因为self.code
中包含其他CodeBuilder片段,这可能会递归调用其它的CodeBuilder对象的__str__
方法。
def __str__(self):
return "".join(str(c) for c in self.code)
get_globals
产生执行代码的最终值。它字符串化对象,执行它并得到它的定义,然后返回最终的值:
def get_globals(self):
"""Execute the code, and return a dict of globals it defines."""
# A check that the caller really finished all the blocks they started.
assert self.indent_level == 0
# Get the Python source as a single string.
python_source = str(self)
# Execute the source, defining globals, and return them.
global_namespace = {}
exec(python_source, global_namespace)
return global_namespace
最后的方法利用了python的魔法特性。exec
函数执行一串包含python代码的字符串,它的第二个参数是一个字典,用来收集字符串代码中定义的全局变量。举例来说,如果我们这样做:
python_source = """\
SEVENTEEN = 17
def three():
return 3
"""
global_namespace = {}
exec(python_source, global_namespace)
然后global_namespace['SEVENTEEN']
就是17,global_namespace['three']
就是一个名为three
的函数。
尽管我们只用CodeBuilder来生成一个函数,但是并没有什么用来限制它。这使得这个类易于实现和理解。
CodeBuilder让我们创建python源代码块,而且一点也没有关于我们的模板引擎的特定知识。我们可以在python中定义三个不同的函数,然后get_globals
返回三个函数的字典。这样,我们的模板引擎只需要定义一个函数。但是更好的软件设计方法是保留实现细节在模板引擎代码中,而不是在CodeBuilder类中。
即使我们真正用它来定义单个函数,拥有一个返回字典的get_globals
函数使代码更加模块化,因为它并不需要知道我们所定义的函数名称。不论我们在python源中如何定义函数名,我们都可以通过get_globals
返回的字典来获取它。
现在我们可以实现Templite类了,以及看看CodeBuilder是怎样使用的。
Templite类的实现
我们的大部分代码都在Templite类中。正如我们之前讨论的,它同时具有编译阶段和渲染阶段。
编译
编译一个模板为python函数的所有工作在Templite构造器里发生。首先上下文被保存:
def __init__(self, text, *contexts):
"""Construct a Templite with the given `text`.
`contexts` are dictionaries of values to use for future renderings.
These are good for filters and global values.
"""
self.context = {}
for context in contexts:
self.context.update(context)
注意我们使用了*contexts
作为参数。星号表示任意数量的位置参数将被打包成一个元组作为contexts
传递进来。这叫做参数解包,意味着调用者可以提供多个不同的上下文字典。现在,如下调用都是有效的:
t = Templite(template_text)
t = Templite(template_text, context1)
t = Templite(template_text, context1, context2)
上下文参数在存在的情况下被作为一个元组提供给构造器。我们可以遍历这个元组,轮流处理它们每一个。我们简单的创建了一个所有上下文字典组合而成的字典,叫做self.context
。如果有重复的字典值,后面的会覆盖前面的。
为了使用编译出来的函数运行得尽可能的快,我们将上下文中的变量提取到python本地变量中。我们将通过保存一个遇到过的变量名的集合来获取它们,但是我们也需要跟踪模板中定义的变量名,如循环变量:
self.all_vars = set()
self.loop_vars = set()
稍后我们将看到这些是如何被用来帮助构建函数的序幕的。首先,我们用了之前写的CodeBuilder类来开始构建我们的编译函数:
code = CodeBuilder()
code.add_line("def render_function(context, do_dots):")
code.indent()
vars_code = code.add_section()
code.add_line("result = []")
code.add_line("append_result = result.append")
code.add_line("extend_result = result.extend")
code.add_line("to_str = str")
在这里,我们构建了我们的CodeBuilder对象,然后向里面加入语句。我们的python函数将被称为render_function
,它接受两个参数:一个是上下文数据字典,一个是实现点属性访问的do_dots
函数。
这里的上下文是两个上下文数据的组合:被传递给Templite构造器的上下文和被传给渲染函数的上下文。这是我们在Templite构造器中创建的模板可获得的一套完整的数据。
请注意,CodeBuilder很简单:它不知道函数的定义,只拥有代码行。这保持CodeBuilder在实现和使用上的简便性。这里,我们可以读取我们生成的代码而不需要在精神上插入太多的专门的CodeBuilder。
我们创建了一个片段叫vars_code
。之后我们将在该片段中写上变量提取的语句。vars_code
对象使我们保留了一个函数中的位置,它将在我们得到需要的信息后被填补。
随后是添加四条固定语句,定义了一个结果列表,添加了列表方法和内置str
方法的快捷方式。正如我们之前讨论的,这个奇怪的步骤为我们的渲染函数挤出来一点点的性能提升。
同时拥有append
和extend
方法的快捷方式使我们面对一行或者多行的添加时,可以选择最有效率的一个。
接下来我们定义一个内部函数来帮助我们缓冲输出字符串:
buffered = []
def flush_output():
"""Force `buffered` to the code builder."""
if len(buffered) == 1:
code.add_line("append_result(%s)" % buffered[0])
elif len(buffered) > 1:
code.add_line("extend_result([%s])" % ", ".join(buffered))
del buffered[:]
当我们创建一堆需要加入编译出的函数的输出块时,我们需要把它们变成加入result
列表的函数调用。我们将反复的append
调用组合为一个extend
调用。这是另一个微优化。要做到这一点,我们缓冲这些输出块。
缓冲列表保存还未被写入函数源代码的字符串。当我们的模板编译运行时,我们将向buffered
添加字符串,然后当我们遇到控制流节点(如if语句,循环的开始或末端)时,将它们刷新到函数源代码。
flus_output
函数是一个闭包,闭包是对于一个引用本身之外变量的函数的花哨称呼。在这里,flus_output
引用了buffered
和code
。这简化了我们的函数调用:我们不必告诉flush_output
刷新哪个缓冲区或者刷新到哪,它隐式地知道这些。
如果只有只有一个字符串被缓冲,那么append_result
将被调用;如果多于一个,extend_result
被使用。然后缓冲队列被清空来缓冲下一批的字符串。
余下的编译代码将是添加语句到缓冲队列。然后最终调用flush_output
来将它们写入CodeBuilder。
有了这个函数,我们可以在编译器中拥有这样一条代码:
buffered.append("'hello'")
这意味着我们编译出来的python函数将有这样一句:
append_result('hello')
即hello
字符串将被添加到模板的渲染输出中。这里,我们有几个层级的抽象,很容易搞混。
编译器使用了buffered.append("'hello'")
来创建一个append_result('hello')
语句在编译出的python函数中,而这个函数语句运行后添加了hello
字符串到最终的模板结果中。
回到我们的Templite类中。当我们解析控制流结构时,我们希望检查它们是否是合理地嵌套了。ops_stack
列表是一个字符串堆栈:
ops_stack = []
例如当我们碰到一个{% if .. %}
标签,我们将'if'
压入堆栈。当我们碰到一个{% endif %}
标签时,我们再将之前的'if'
弹出堆栈。如果栈顶没有'if'
则报告错误。
现在真正的解析开始。我们使用正则表达式将模板文本分割多个标志。正则表达式可能是令人畏惧的:它们是非常紧凑的符号,用来做复杂的模式匹配。它们也非常高效,因为模式匹配的复杂部分是正则表达式引擎中用C实现的,而不是你自己的python代码。这是我们的正则表达式:
tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)
这看上去很复杂,来让我们分解它。
re.split
函数将使用正则分割一个字符串。我们的模式在括号里,匹配的符号将被用来分割字符串,分割出的字符串将组成列表返回。我们的模式代表了我们的标签语法,我们将它括起来使字符串会在标签处被分割,然后标签也会被返回。
re.split
的返回值是一个字符串列表。例如,这是模板文本:
<p>Topics for {{name}}: {% for t in topics %}{{t}}, {% endfor %}</p>
它将被分割成如下的片段:
[
'<p>Topics for ', # literal
'{{name}}', # expression
': ', # literal
'{% for t in topics %}', # tag
'', # literal (empty)
'{{t}}', # expression
', ', # literal
'{% endfor %}', # tag
'</p>' # literal
]
一旦文本被分割成这样的标记,我们就可以循环依次处理它们。根据类型来分割它们,我们就可以分别处理每个类型。
编译代码是一个关于这些标记的循环:
for token in tokens:
每个标记都被检查,看它是四种情况中的哪一个。只看头两个字符就够了。第一种情况是注释,只需要忽略它然后继续处理下一个标记就行了:
if token.startswith('{#'):
# Comment: ignore it and move on.
continue
对于{{...}}
表达式,我们截断前后的大括号,用空格分割,然后整个传递给_expr_code
函数:
elif token.startswith('{{'):
# An expression to evaluate.
expr = self._expr_code(token[2:-2].strip())
buffered.append("to_str(%s)" % expr)
_expr_code
方法将编译模板表达式为python语句。留后再看。我们使用了to_str
函数来强制返回的表达式的值为字符串,然后将它加到我们的结果列表中。
第三种情况是{% ... %}
标签。要将它们变为python的控制结构。首先我们刷新我们的输出语句缓冲队列,然后我们从标签中提取单词列表:
elif token.startswith('{%'):
# Action tag: split into words and parse further.
flush_output()
words = token[2:-2].strip().split()
现在我们有三个子情况,取决于标签的第一个词:if
,for
或者end
。if
情况至少展示了简单的错误处理和代码生成:
if words[0] == 'if':
# An if statement: evaluate the expression to determine if.
if len(words) != 2:
self._syntax_error("Don't understand if", token)
ops_stack.append('if')
code.add_line("if %s:" % self._expr_code(words[1]))
code.indent()
if
标签只能有一个表达式,所以words
列表只应该有两个元素。如果不是,我们利用_syntax_error
辅助方法来抛出一个语法异常。我们将'if'
压入ops_stack
栈中,来让我们检查相应的endif
标签。'if'
标签的表达式部分通过_expr_code
编译为python表达式,然后被用作python中if
语句的条件表达式。
第二个标签的类型是for
,它将被编译为一个python的for
语句:
elif words[0] == 'for':
# A loop: iterate over expression result.
if len(words) != 4 or words[2] != 'in':
self._syntax_error("Don't understand for", token)
ops_stack.append('for')
self._variable(words[1], self.loop_vars)
code.add_line(
"for c_%s in %s:" % (
words[1],
self._expr_code(words[3])
)
)
code.indent()
我们做了一个语法检查并且将for
压入栈中。_variable
方法检查了变量的语法,并且将它加入我们提供的集合。这就是我们在编译过程中收集所有变量的名称的方法。之后我们需要编写我们的函数的序幕,那时我们将解包所有从上下文得到的变量名。为了能正确地完成该操作,我们需要知道所有我们碰到过的变量名,self.all_vars
和所有循环中定义的变量名,self.loop_vars
。
然后我们添加一行for
语句到我们的函数源码中。所有的模板变量都加上c_
前缀被转换为python变量,所以我们知道它们不会与其它命名冲突。我们使用_expr_code
函数来编译模板中的迭代表达式到python中的迭代表达式。
最后一种标签就是end
了,不论是{% endif %}
还是{% endfor %}
。效果对于我们编译出的函数源码是一样的:只是简单地在之前的if
或for
语句末尾加上取消缩进:
elif words[0].startswith('end'):
# Endsomething. Pop the ops stack.
if len(words) != 1:
self._syntax_error("Don't understand end", token)
end_what = words[0][3:]
if not ops_stack:
self._syntax_error("Too many ends", token)
start_what = ops_stack.pop()
if start_what != end_what:
self._syntax_error("Mismatched end tag", end_what)
code.dedent()
注意这里真正需要的工作只是最后一行:取消函数源码的缩进。余下的语句都是错误检查来保证模板被正确地组织了。这在程序翻译代码中很常见。
说道错误处理,如果标签不是一个if
、for
或者end
,那么我们也不知道它是什么,所以抛出一个语法异常:
else:
self._syntax_error("Don't understand tag", words[0])
我们对三个特殊语法({{...}}
, {#...#}
, 以及{%...%}
)的处理已经完成了。剩下的就是文字内容。我们添加文字内容到缓冲输出队列,记得使用内置的repr
函数来产生一个python字符串字面量:
else:
# Literal content. If it isn't empty, output it.
if token:
buffered.append(repr(token))
否则,可能会在我们编译出的函数中出现下面的语句:
append_result(abc) # Error! abc isn't defined
我们需要值被这样引用:
append_result('abc')
repr
函数提供了对字符串的引用,并且会在需要的地方提供反斜杠:
append_result('"Don\'t you like my hat?" he asked.')
注意我们一开始用if token:
检查了该标记是否是空的,因为添加一个空字符串到输出中是没有意义的。因为我们的正则表达式是按标签语法分割的,邻近的标签会造成一个空标记在它俩之间。这里的检查是一种避免无用的append_result("")
语句出现在编译出的函数中的简易方法。
这样就完成了模板中所有标记的循环。当循环结束,模板中的所有地方都被处理了。我们还有一个检查要做,那就是如果ops_stack
不为空,我们一定漏掉了一个结束标签。然后我们刷新缓冲队列输出到函数源码。
if ops_stack:
self._syntax_error("Unmatched action tag", ops_stack[-1])
flush_output()
在函数的一开始我们已经创建了一个片段。它的角色是从上下文中解包模板变量到python本地变量。既然我们已经处理完了这个模板,我们也知道所有变量的名称,我们就可以在函数序幕中写语句。
我们必须做一点小工作来知道我们需要定义什么名称。看我们的示例模板:
<p>Welcome, {{user_name}}!</p>
<p>Products:</p>
<ul>
{% for product in product_list %}
<li>{{ product.name }}:
{{ product.price|format_price }}</li>
{% endfor %}
</ul>
这里有两个被用到的变量,user_name
和product
。all_vars
集合将拥有他们的名称,因为它们都被用在{{...}}
表达式中。但是只有user_name
需要在序幕中从上下文提取,因为product
是在循环中定义的。
模板中所有的变量都在集合all_vars
中,模板中定义的变量都在loop_vars
中。所有loop_vars
中的变量名称都已经在代码中被定义了,因为它们在循环中被使用了。所以我们需要解包任何属于all_vars
而不属于loop_vars
的名称:
for var_name in self.all_vars - self.loop_vars:
vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))
每个名称都变成函数序幕的一行代码,将上下文变量解包成合法的本地变量。
我们快要完成将模板变为python函数的编译。我们的函数一直在将字符串加入result
中,所以最后一行是简单地将它们组合在一起并返回:
code.add_line("return ''.join(result)")
code.dedent()
既然我们已经完成了编译出的python函数的源码的书写,我们需要的就是从CodeBuilder对象得到函数本身。get_globals
方法执行我们组装好的python代码。记住我们的代码是一个函数定义(以def render_function(..):
开始),所以执行这个代码会定义render_function
,但是并不执行render_function
的主体。
get_globals
的结果是代码中定义的值的字典。我们从中获取render_function
的值,然后将它保存为Templite对象的一个属性:
self._render_function = code.get_globals()['render_function']
现在self._render_function
就是一个可调用的python函数了。我们会在渲染阶段使用它。
编译表达式
我们还没有看到编译过程的一个重要部分:_expr_code
方法,它将模板表达式编译为python表达式。我们的模板表达式可能简单的只是一个名字:
{{user_name}}
也可能是一个复杂的序列包含属性访问和过滤器:
{{user.name.localized|upper|escape}}
我们的_expr_code
方法将处理所有的可能情况。正如所有语言中的表达式,我们的也是递归构建的:大的表达式由小的表达式组成。一个完整的表达式由管道符分隔,其中第一部分是由逗号分隔的,诸如此类。所以我们的函数自然地采取递归的形式:
def _expr_code(self, expr):
"""Generate a Python expression for `expr`."""
第一种情况是考虑我们的表达式中有管道分隔符。如果有,我们要分割它为一个管道片段列表。第一部分将被递归地传入_expr_code
来将它转换为一个python表达式。
if "|" in expr:
pipes = expr.split("|")
code = self._expr_code(pipes[0])
for func in pipes[1:]:
self._variable(func, self.all_vars)
code = "c_%s(%s)" % (func, code)
余下的每一个管道片段都是一个函数名。值被传递给这些函数来产生最终的值。每一个函数名都是一个变量,要加入到all_vars
中所以我们能在序幕中正确地提取它。
如果没有管道,可能会有点操作符。如果有的话,按点分割。将第一部分递归地传递给_expr_code
来将它转换为一个python表达式,之后以点分割的名称都被依次处理。
elif "." in expr:
dots = expr.split(".")
code = self._expr_code(dots[0])
args = ", ".join(repr(d) for d in dots[1:])
code = "do_dots(%s, %s)" % (code, args)
要理解点操作是如何被编译的,记住模板中的x.y
意味着x['y']
或者x.y
,谁能工作用谁,如果结果是可调用的,就调用它。这种不确定性意味着我们不得不在运行时尝试所有的可能,而不是在编译时确定。所以我们编译x.y.z
为一个函数调用,do_dots(x, 'y', 'z')
。点函数将尝试不同的访问方法并返回成功的值。
do_dots
函数将在运行时传入我们编译好的python函数。我们将看到它的实现。
_expr_code
函数最后的语句是处理没有管道和点操作符的情况。这时,表达式仅仅是一个名称。我们将它记录在all_vars
中,并且通过带前缀的python命名获取它:
else:
self._variable(expr, self.all_vars)
code = "c_%s" % expr
return code
辅助函数
在编译过程中,我们使用了一些辅助函数。比如_syntax_error
方法仅仅组合出漂亮的错误信息并抛出异常:
def _syntax_error(self, msg, thing):
"""Raise a syntax error using `msg`, and showing `thing`."""
raise TempliteSyntaxError("%s: %r" % (msg, thing))
_variable
方法帮助我们验证变量名是否有效,然后将它们加入在编译过程中我们收集的姓名集合。我么是用正则来检查名称是否是一个有效的python标识符,然后将它加入集合:
def _variable(self, name, vars_set):
"""Track that `name` is used as a variable.
Adds the name to `vars_set`, a set of variable names.
Raises an syntax error if `name` is not a valid name.
"""
if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name):
self._syntax_error("Not a valid name", name)
vars_set.add(name)
就这样,编译部分的代码都完成了。
渲染
剩下的就是编写渲染代码了。因为我们将模板编译为python函数,所以渲染部分没有太多工作要做。它准备好上下文,然后调用编译好的python代码:
def render(self, context=None):
"""Render this template by applying it to `context`.
`context` is a dictionary of values to use in this rendering.
"""
# Make the complete context we'll use.
render_context = dict(self.context)
if context:
render_context.update(context)
return self._render_function(render_context, self._do_dots)
想起来,我们在构建Templite
对象时,我们从一个数据上下文开始。这里我们复制它,然后将它和渲染函数被传入的数据混合。复制是为了让连续的多个渲染函数调用不会看到相互的数据,然后将它们混合是为了让我们只有一个字典来进行数据查找。这就是我们如何从提供的多个上下文(模板被创建时和渲染时)中构建出一个统一的数据上下文。
要注意的是,我们传递给render
的数据可能会覆盖传递给Templite构造器的。这往往不会发生,因为传递给构造器的上下文包含的是全局定义的过滤器和常量,而传给render
的上下文包含的是那一次渲染的特有数据。
然后我们简要地调用我们的render_function
。第一个参数是完整的上下文数据,第二个是将被实现的点语义函数。我们每次使用同样的实现:我们的_do_dots
方法。
def _do_dots(self, value, *dots):
"""Evaluate dotted expressions at runtime."""
for dot in dots:
try:
value = getattr(value, dot)
except AttributeError:
value = value[dot]
if callable(value):
value = value()
return value
在编译期间,一个模板表达式如x.y.z
被转换为do_dots(x, 'y', 'z')
。这个函数循环每个点后的名称,对每一个它先尝试是否是一个属性,不是的话再看它是否是一个字典的键。这给予了我们的单个模板语法一定的自由度来同时表示x.y
和x['y']
。在每一步,我们都尝试调用它。一旦我们完成了循环,value
的值就是我们想要的值。
这里我们再次使用了python的参数解包(*dots
)以至于_do_dots
能够处理任意多的点操作。这增加了我们的函数的适用性,能为所有模板中的点表达式工作。
注意的是当调用self._render_function
时,我们传递了一个函数来评定点表达式,但是我们总是传递同一个。我们能够使它成为被编译的模板的一部分,但是这些行是关于模板的工作方式,而不是特定模板的部分细节。所以像这样分开实现让人感觉结构更清晰。
测试
和模板引擎一起提供的是一系列测试覆盖了所有行为和边缘情况。我实际上有点超出500行的限制:模板引擎有252行,测试有275行。这是一个典型的测试完备的代码:你的测试代码比你的产品代码还多。
可以完善的地方
完备特性的模板引擎比我们在这里实现的要复杂得多。为了保证代码量小,我们遗留了许多有趣的问题:
- 模板继承和包含
- 自定义标签
- 自动换码
- 参数过滤器
- 复杂条件逻辑如else和elif
- 不止一个循环变量的循环
- 空白的控制
即便如此,我们的简单模板引擎也足够有用了。实际上,它被用来为coverage.py生成它的HTML报告。
总结
在252行中,我们得到了一个简单但是有一定功能的模板引擎。真实的模板引擎具有更多的特性,但是这个代码勾画出整个过程的基本思路:将模板编译成一个python函数,然后执行这个函数来生成最终的文本结果。
网友评论