
Python的exec():执行动态生成的代码
Python 的内置 exec()函数允许你从字符串或编译后的代码输入中执行任意的 Python 代码。
当你需要运行动态生成的Python代码时,exec() 函数会很方便,但如果你不小心使用它,它可能会很危险。在本教程中,你不仅将学习如何使用exec() ,而且同样重要的是,在你的代码中何时可以使用这个函数。
在本教程中,你将学习如何。
- 使用Python的内置
exec()函数 - 使用
exec(),执行以字符串或编译代码对象形式出现的代码 - 评估并尽量减少与在你的代码中使用
exec()有关的安全风险
此外,你将写一些使用exec() 来解决与动态代码执行有关的不同问题的例子。
为了从本教程中获得最大的收获,你应该熟悉 Python 的命名空间和范围,以及字符串。你还应该熟悉一些 Python 的内置函数。
熟悉Python的exec()
Python 的内置 exec()函数允许你执行任何一段 Python 代码。通过这个函数,你可以执行动态生成的代码。那是你在程序执行过程中读到的、自动生成的、或获得的代码。通常情况下,它是一个字符串。
exec() 函数接收一段代码,并像你的Python解释器那样执行它。Python的exec() ,就像 eval()但功能更强大,也更容易出现安全问题。虽然eval() 只能评估表达式,但exec() 可以执行语句序列,以及导入、函数调用和定义、类定义和实例化等等。基本上,exec() 可以执行整个功能齐全的 Python 程序。
exec() 的签名有如下形式。
exec(code [, globals [, locals]])
该函数执行code ,它可以是一个包含有效 Python 代码的字符串,也可以是一个已编译的代码对象。
注意:Python是一种解释型语言,而不是编译型语言。然而,当你运行一些 Python 代码时,解释器会将其翻译成字节码,这是CPython实现中 Python 程序的内部表示。这种中间翻译也被称为编译代码,是Python的虚拟机所执行的。
如果code 是一个字符串,那么它被解析为一套 Python 语句,然后被内部编译为字节码,最后被执行,除非在解析或编译步骤中发生语法错误。如果code 持有一个已编译的代码对象,那么它就直接被执行,使这个过程更有效率。
globals 和locals 参数允许你提供代表全局和局部命名空间的字典,exec() 将在其中运行目标代码。
exec() 函数的返回值是 None,可能是因为不是每一段代码都有一个最终的、唯一的、具体的结果。它可能只是有一些副作用。这种行为明显不同于eval() ,后者返回被评估表达式的结果。
为了初步了解exec() 是如何工作的,你可以用两行代码创建一个初级的 Python 解释器。
>>>
>>> while True:
... exec(input("->> "))
...
->> print("Hello, World!")
Hello, World!
->> import this
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
...
->> x = 10
->> if 1 <= x <= 10: print(f"{x} is between 1 and 10")
10 is between 1 and 10
在这个例子中,你用一个无限的 while循环来模仿 Python解释器或REPL 的行为。在这个循环中,你用 input()来获取用户在命令行的输入。然后你用exec() 来处理和运行输入。
这个例子展示了可以说是exec() 的主要用例:执行以字符串形式出现的代码。
注意:你已经了解到,使用exec() 可能意味着安全风险。现在你已经看到了exec() 的主要用例,你认为这些安全风险可能是什么?你会在本教程的后面找到答案。
当你需要动态地运行以字符串形式出现的代码时,你通常会使用exec() 。例如,你可以编写一个程序,生成包含有效 Python 代码的字符串。你可以从你在程序执行的不同时刻获得的部分建立这些字符串。你也可以使用用户的输入或任何其他输入源来构建这些字符串。
一旦你把目标代码构建成字符串,那么你就可以使用exec() ,像执行任何Python代码一样来执行它们。
在这种情况下,你很少能确定你的字符串将包含什么。这就是为什么exec() 意味着严重的安全风险的一个原因。如果你在构建你的代码时使用不受信任的输入源,如用户的直接输入,这一点尤其正确。
在编程中,像exec() 这样的函数是一个令人难以置信的强大工具,因为它允许你编写动态生成和执行新代码的程序。为了生成这些新代码,你的程序将使用仅在运行时可用的信息。要运行这些代码,你的程序将使用exec() 。
然而,巨大的权力伴随着巨大的责任。exec() 函数意味着严重的安全风险,你很快就会知道。所以,你应该在大多数时候避免使用exec() 。
在下面的章节中,你将了解到exec() 是如何工作的,以及如何使用这个函数来执行以字符串或编译代码对象形式出现的代码。
从字符串输入运行代码
调用exec() ,最常见的方式是使用来自基于字符串输入的代码。为了建立这种基于字符串的输入,你可以使用。
- 单行代码或单行代码片断
- 由分号分隔的多行代码
- 由换行符分隔的多行代码
- 在三引号字符串中的多行代码,并有适当的缩进。
一个单行程序由一次执行多个动作的单行代码组成。假设你有一个数字序列,你想建立一个新的序列,其中包含一个输入序列中所有偶数的平方之和。
为了解决这个问题,你可以使用下面这段单行代码。
>>>
>>> numbers = [2, 3, 7, 4, 8]
>>> sum(number**2 for number in numbers if number % 2 == 0)
84
在突出显示的一行中,你使用一个生成器表达式来计算输入值序列中所有偶数的平方值。然后你用 sum()来计算总的平方和。
要用exec() 来运行这段代码,你只需要把单行代码转换成单行字符串。
>>>
>>> exec("result = sum(number**2 for number in numbers if number % 2 == 0)")
>>> result
84
在这个例子中,你将单行代码表达为一个字符串。然后你把这个字符串输入到exec() ,以便执行。你的原始代码和字符串之间的唯一区别是,后者将计算结果存储在一个变量中,以便以后访问。请记住,exec() 返回None ,而不是一个具体的执行结果。为什么呢?因为不是每一段代码都有一个最终的唯一结果。
Python 允许你在一行代码中写多个语句,用分号来分隔它们。尽管这种做法是不鼓励的,但没有什么可以阻止你做这样的事情。
>>>
>>> name = input("Your name: "); print(f"Hello, {name}!")
Your name: Leodanis
Hello, Leodanis!
你可以使用分号来分隔多个语句,并建立一个单行字符串,作为exec() 的参数。 下面是方法。
>>>
>>> exec("name = input('Your name: '); print(f'Hello, {name}!')")
Your name: Leodanis
Hello, Leodanis!
这个例子的意思是,你可以通过使用分号来分隔多个Python语句,将它们组合成一个单行字符串。在这个例子中,第一个语句接受用户的输入,而第二个语句在屏幕上打印一个问候信息。
你也可以使用换行符(\n )将多个语句聚集在一个单行字符串中。
>>>
>>> exec("name = input('Your name: ')\nprint(f'Hello, {name}!')")
Your name: Leodanis
Hello, Leodanis!
换行字符使exec() 将你的单行字符串理解为一组多行的 Python 语句。然后,exec() 在一行中运行聚合的语句,这就像一个多行代码文件。
构建一个基于字符串的输入来喂养exec() 的最后一种方法是使用三引号字符串。这种方法可以说更加灵活,允许你生成基于字符串的输入,看起来和工作起来都像正常的 Python 代码。
值得注意的是,这种方法要求你使用正确的缩进和代码格式。考虑一下下面的例子。
>>>
>>> code = """
... numbers = [2, 3, 7, 4, 8]
...
... def is_even(number):
... return number % 2 == 0
...
... even_numbers = [number for number in numbers if is_even(number)]
...
... squares = [number**2 for number in even_numbers]
...
... result = sum(squares)
...
... print("Original data:", numbers)
... print("Even numbers:", even_numbers)
... print("Square values:", squares)
... print("Sum of squares:", result)
... """
>>> exec(code)
Original data: [2, 3, 7, 4, 8]
Even numbers: [2, 4, 8]
Square values: [4, 16, 64]
Sum of squares: 84
在这个例子中,你用一个三引号的字符串为exec() 提供输入。注意,这个字符串看起来像任何普通的 Python 代码片段。它使用了适当的缩进、命名方式和格式化。exec() 函数将理解并执行这个字符串,作为一个普通的 Python 代码文件。
你应该注意,当你把一个带有代码的字符串传给exec() 时,该函数将解析并把目标代码编译成 Python 字节码。在所有情况下,输入的字符串应该包含有效的 Python 代码。
如果exec() 在解析和编译步骤中发现任何无效的语法,那么输入的代码就不会运行。
>>>
>>> exec("print('Hello, World!)")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 1
print('Hello, World!)
^
SyntaxError: unterminated string literal (detected at line 1)
在这个例子中,目标代码包含对print() 的调用,该调用需要一个字符串作为参数。这个字符串没有正确地以单引号结尾,所以exec() 提出一个SyntaxError 指出这个问题,并且不运行输入代码。注意,Python 在字符串的开头而不是结尾处指出了错误,结尾的单引号应该在那里。
运行以字符串形式出现的代码,像你在上面的例子中做的那样,可以说是使用exec() 的自然方式。然而,如果你需要多次运行输入的代码,那么使用字符串作为参数会使函数每次都运行解析和编译步骤。这种行为会使你的代码在执行速度上效率低下。
在这种情况下,最方便的方法是事先编译目标代码,然后根据需要用exec() ,多次运行得到的字节码。在下一节中,你将学习如何用已编译的代码对象使用exec() 。
执行已编译的代码
在实践中,当你用它来处理包含代码的字符串时,exec() 可能是相当慢的。如果你曾经需要动态地运行一段给定的代码超过一次,那么事先编译它将是最有性能的,也是最值得推荐的方法。为什么呢?因为你将只运行一次解析和编译步骤,然后重复使用编译后的代码。
要编译一段Python代码,你可以使用 compile().这个内置函数把一个字符串作为参数,对它进行一次性的字节码编译,生成一个代码对象,然后把它传给exec() 来执行。
compile() 的签名有如下形式。
compile(source, filename, mode, flags=0, dont_inherit=False, optimize=-1)
在本教程中,你将只使用compile() 的前三个参数。source 参数持有你需要编译成字节码的代码。filename 参数将保存从该代码中读取的文件。要从一个字符串对象中读取,你必须将filename 设置为"<string>" 的值。
最后,compile() 可以生成代码对象,你可以使用exec() 或eval() 执行,这取决于mode 参数的值。这个参数应该被设置为"exec" 或"eval" ,这取决于目标执行函数。
>>>
>>> string_input = """
... def sum_of_even_squares(numbers):
... return sum(number**2 for number in numbers if number % 2 == 0)
...
... print(sum_of_even_squares(numbers))
... """
>>> compiled_code = compile(string_input, "<string>", "exec")
>>> exec(compiled_code)
>>> numbers = [2, 3, 7, 4, 8]
>>> exec(compiled_code)
84
>>> numbers = [5, 3, 9, 6, 1]
>>> exec(compiled_code)
36
在前面用compile() 编译经常重复的代码,可以帮助你跳过每次调用exec() 的解析和字节码编译步骤,从而稍微提高代码的性能。
从 Python 源文件运行代码
你也可以使用exec() 来运行你从文件系统或其他地方的可靠的.py 文件中读取的代码。要做到这一点,你可以使用内置的 open()函数把文件的内容读成一个字符串,然后把它作为参数传给exec() 。
例如,假设你有一个名为hello.py 的 Python 文件,包含以下代码。
# hello.py
print("Hello, Pythonista!")
print("Welcome to Real Python!")
def greet(name="World"):
print(f"Hello, {name}!")
这个样本脚本在屏幕上打印了一个问候语和一个欢迎词。它还定义了一个用于测试的示例函数greet() 。这个函数接受一个名字作为参数,并在屏幕上打印出一个自定义的问候语。
现在回到你的Python交互式会话,运行以下代码。
>>>
>>> with open("hello.py", mode="r", encoding="utf-8") as hello:
... code = hello.read()
...
>>> exec(code)
Hello, Pythonista!
Welcome to Real Python!
>>> greet()
Hello, World!
>>> greet("Pythonista")
Hello, Pythonista!
在这个例子中,你首先在一个with 语句中使用内置的open() 函数将目标.py 文件作为一个普通文本文件打开。然后你调用 .read()在文件对象上读取文件的内容到code 变量中。这个对.read() 的调用将文件的内容作为一个字符串返回。最后一步是用这个字符串作为参数调用exec() 。
这个例子运行代码并使greet() 函数和住在hello.py 的对象在你当前的名字空间中可用。这就是为什么你可以直接使用greet() 。这种行为背后的秘密与globals 和locals 参数有关,你将在下一节中了解到这一点。
使用上面例子中的技术,你可以打开、读取和执行任何包含 Python 代码的文件。当你事先不知道要运行哪些源文件时,这种技术可能会起作用。所以,你不能写import module ,因为你在写代码时不知道模块的名字。
注意:在 Python 中,你会找到更安全的方法来获得类似的结果。例如,你可以使用导入系统。
如果你选择使用这种技术,那么要确保你只执行来自可信的源文件的代码。理想情况下,最可靠的源文件是那些你有意识地创建的动态运行的文件。你决不能在没有先检查代码的情况下运行来自外部来源的代码文件,包括你的用户。
使用globals 和locals 参数
你可以使用globals 和locals 参数向exec() 传递一个执行环境。这些参数可以接受字典对象,这些对象将作为全局和局部命名空间,exec() 将用来运行目标代码。
这些参数是可选的。如果你省略了它们,那么exec() 将在当前范围内执行输入的代码,并且这个范围内的所有名字和对象都可以被exec() 。同样地,在调用exec() 后,你在输入代码中定义的所有名称和对象将在当前作用域中可用。
考虑一下下面的例子。
>>>
>>> code = """
... z = x + y
... """
>>> # Global names are accessible from exec()
>>> x = 42
>>> y = 21
>>> z
Traceback (most recent call last):
...
NameError: name 'z' is not defined
>>> exec(code)
>>> # Names in code are available in the current scope
>>> z
63
这个例子表明,如果你调用exec() 而不给globals 和locals 参数提供具体的值,那么该函数会在当前作用域中运行输入代码。在这种情况下,当前的作用域是全局的。
请注意,在你调用exec() 之后,在输入代码中定义的名字也可以在当前作用域中使用。这就是为什么你可以在最后一行代码中访问z 。
如果你只向globals 提供一个值,那么这个值必须是一个字典。exec() 函数将对全局和局部名称使用这个字典。这种行为将限制对当前范围内大多数名字的访问。
>>>
>>> code = """
... z = x + y
... """
>>> x = 42
>>> y = 21
>>> exec(code, {"x": x})
Traceback (most recent call last):
...
NameError: name 'y' is not defined
>>> exec(code, {"x": x, "y": y})
>>> z
Traceback (most recent call last):
...
NameError: name 'z' is not defined
在第一次调用exec() 时,你用一个 dictionary 作为globals 的参数。因为你的字典没有提供一个持有y 名称的键,所以对exec() 的调用不能访问这个名称,并引发一个NameError 异常。
在第二次调用exec() 时,你向globals 提供了一个不同的 dictionary。在这种情况下,这个 dictionary 包含了两个变量:x 和y ,这使得函数能够正常工作。然而,这一次在调用exec() 之后,你不能访问z 。为什么?因为你用一个自定义的字典为exec() 提供一个执行范围,而不是回落到你当前的范围。
如果你用一个globals 字典调用exec() ,而这个字典没有明确包含__builtins__ 的 key,那么 Python 将自动在这个 key 下插入对内置作用域或命名空间的引用。所以,所有的内置对象都可以从你的目标代码中访问。
>>>
>>> code = """
... print(__builtins__)
... """
>>> exec(code, {})
{'__name__': 'builtins', '__doc__': "Built-in functions, ...}
在这个例子中,你给globals 参数提供了一个空字典。注意exec() 仍然可以访问内置的名字空间,因为这个名字空间被自动插入到__builtins__ 这个键下提供的字典中。
如果你为locals 参数提供了一个值,那么它可以是任何映射对象。当exec() 运行你的目标代码时,这个映射对象将持有本地命名空间。
>>>
>>> code = """
... z = x + y
... print(f"{z=}")
... """
>>> x = 42 # Global name
>>> def func():
... y = 21 # Local name
... exec(code, {"x": x}, {"y": y})
...
>>> func()
z=63
>>> z
Traceback (most recent call last):
...
NameError: name 'z' is not defined
在这个例子中,对exec() 的调用被嵌入到一个函数中。因此,你有一个全局(模块级)范围和一个局部(函数级)范围。globals 参数提供了全局范围的x 名称,而locals 参数提供了局部范围的y 名称。
注意,在运行func() ,你不能访问z ,因为这个名字是在exec() 的局部作用域下创建的,从外部是不能访问的。
通过globals 和locals 参数,你可以调整exec() 运行你的代码的环境。当涉及到最小化与exec() 有关的安全风险时,这些参数是相当有帮助的,但你仍然应该确保你只运行来自可信来源的代码。在下面的章节中,你将了解到这些安全风险以及如何处理它们。
揭示并尽量减少背后的安全风险exec()
正如你到目前为止所了解的,exec() 是一个强大的工具,它允许你执行以字符串形式出现的任意代码。你应该极其小心和谨慎地使用exec() ,因为它有能力运行任何一段代码。
通常情况下,为exec() 提供的代码是在运行时动态生成的。这段代码可能有许多输入源,其中可能包括你的程序用户、其他程序、数据库、数据流和网络连接,等等。
在这种情况下,你不可能完全确定输入字符串会包含什么。所以,面对一个不受信任的恶意输入代码源的概率是相当高的。
与exec() 有关的安全问题是许多Python开发者建议完全避免这个函数的最常见原因。找到一个更好、更快、更强大、更安全的解决方案几乎是可能的。
然而,如果你必须在你的代码中使用exec() ,那么一般推荐的方法是与显式globals 和locals 字典一起使用它。
exec() 的另一个关键问题是,它打破了编程中的一个基本假设:当你启动你的程序时,你目前正在读或写的代码就是你将要执行的代码。 exec() 是如何打破这一假设的?它使你的程序运行动态生成的新的和未知的代码。这种新的代码可能很难被跟踪、维护,甚至是控制。
在下面的章节中,你将深入了解一些建议、技术和实践,如果你需要在你的代码中使用exec() ,你应该应用这些建议。
避免来自不信任的来源的输入
如果你的用户可以在运行时向你的程序提供任意的 Python 代码,那么如果他们输入的代码违反或破坏了你的安全规则,就会出现问题。为了说明这个问题,回到使用exec() 执行代码的 Python 解释器的例子。
>>>
>>> while True:
... exec(input("->> "))
...
->> print("Hello, World!")
Hello, World!
现在假设你想用这种技术在你的一个Linux网络服务器上实现一个交互式Python解释器。如果你允许你的用户直接将任意代码传入你的程序,那么一个恶意的用户可能会提供类似"import os; os.system('rm -rf *')" 。这个代码片段可能会删除你的服务器磁盘上的所有内容,所以不要运行它。
为了防止这种风险,你可以通过利用globals 字典来限制对import 系统的访问。
>>>
>>> exec("import os", {"__builtins__": {}}, {})
Traceback (most recent call last):
...
ImportError: __import__ not found
import 系统内部使用内置的__import__() 函数。因此,如果你禁止对内置命名空间的访问,那么import 系统就不会工作。
即使你可以如上面的例子所示,对globals 字典进行调整,但有一件事你绝对不能做,那就是使用exec() 在你自己的计算机上运行外部的、可能不安全的代码。即使你仔细清理并验证了输入,你也会有被黑的危险。所以,你最好避免这种做法。
限制globals 和locals ,以最大限度地减少风险
如果你想在使用exec() 运行代码时微调对全局和局部名称的访问,你可以提供自定义字典作为globals 和locals 参数。例如,如果你向globals 和locals 传递空字典,那么exec() 将无法访问你当前的全局和局部命名空间。
>>>
>>> x = 42
>>> y = 21
>>> exec("print(x + y)", {}, {})
Traceback (most recent call last):
...
NameError: name 'x' is not defined
如果你用空字典调用exec() ,globals 和locals ,那么你就禁止访问全局和局部名字。这种调整允许你在使用exec() 运行代码时限制可用的名称和对象。
然而,这种技术并不能保证安全使用exec() 。为什么?因为该函数仍然可以访问所有的 Python 内置名称。
>>>
>>> exec("print(min([2, 3, 7, 4, 8]))", {}, {})
2
>>> exec("print(len([2, 3, 7, 4, 8]))", {}, {})
5
在这些例子中,你为globals 和locals 使用了空字典,但exec() 仍然可以访问内置的函数,如 min(), len(),和 print().你如何防止exec() 访问内置名称?这就是下一节的主题。
决定允许的内置名称
正如你已经学过的,如果你向globals 传递一个没有__builtins__ 键的自定义字典,那么 Python 将自动用新的__builtins__ 键下的内置范围内的所有名字更新这个字典。为了限制这种隐含的行为,你可以使用一个包含有适当值的__builtins__ key 的globals dictionary。
例如,如果你想完全禁止对内置名称的访问,那么你可以像下面的例子那样调用exec() 。
>>>
>>> exec("print(min([2, 3, 7, 4, 8]))", {"__builtins__": {}}, {})
Traceback (most recent call last):
...
NameError: name 'print' is not defined
在这个例子中,你把globals 设置为一个包含__builtins__ key 的自定义字典,其关联值为空字典。这种做法可以防止 Python 将对内置名字空间的引用插入globals 中。这样,你就保证了在执行你的代码时,exec() 不会访问内置的名字。
如果你需要exec() 只访问某些内置名,你也可以调整你的__builtins__ 键。
>>>
>>> allowed_builtins = {"__builtins__": {"min": min, "print": print}}
>>> exec("print(min([2, 3, 7, 4, 8]))", allowed_builtins, {})
2
>>> exec("print(len([2, 3, 7, 4, 8]))", allowed_builtins, {})
Traceback (most recent call last):
...
NameError: name 'len' is not defined
在第一个例子中,exec() 成功地运行了你的输入代码,因为min() 和print() 存在于与__builtins__ 键相关的字典中。在第二个例子中,exec() 引发了一个NameError ,并且没有运行你的输入代码,因为len() 不存在于提供的allowed_builtins 中。
上述例子中的技术允许你将使用exec() 的安全影响降到最低。然而,这些技术并不是完全万无一失的。所以,每当你觉得需要使用exec() ,就尽量想出另一个不使用该函数的解决方案。
将exec() 付诸行动
到此为止,你已经了解了内置的exec() 函数是如何工作的。你知道你可以使用exec() 来运行基于字符串或编译代码的输入。你还知道这个函数可以接受两个可选的参数,globals 和locals ,这允许你调整exec() 的执行命名空间。
此外,你还了解到,使用exec() 意味着一些严重的安全问题,包括允许用户在你的计算机上运行任意的 Python 代码。你学习了一些推荐的编码方法,这些方法有助于将代码中与exec() 相关的安全风险降到最低。
在下面的章节中,你将编码一些实际的例子,这些例子将帮助你发现那些使用exec() 可能是合适的使用情况。
从外部来源运行代码
使用exec() 来执行来自你的用户或任何其他来源的字符串代码,可能是exec() 最常见和最危险的用例。这个函数是你接受字符串代码并在给定程序的上下文中作为常规 Python 代码运行的最快捷方式。
你决不能使用exec() 在你的机器上运行任意的外部代码,因为没有安全的方法。如果你要使用exec() ,那么就把它作为一种方式,让你的用户在他们自己的机器上运行他们自己的代码。
标准库有一些模块使用exec() ,用于执行用户提供的字符串的代码。一个很好的例子是 timeit模块,它是Guido van Rossum最初自己写的。
timeit 模块提供了一种快速的方法来为以字符串形式出现的小段 Python 代码计时。请看下面这个来自模块文档的例子。
>>>
>>> from timeit import timeit
>>> timeit("'-'.join(str(n) for n in range(100))", number=10000)
0.1282792080000945
timeit() 函数接收一个字符串形式的代码片断,运行该代码,并返回执行时间的测量值。该函数还接受其他几个参数。例如,number 允许你提供你想要执行目标代码的次数。
在这个函数的核心,你会发现有一个 Timer类。Timer 使用exec() 来运行提供的代码。如果你检查Timer 的源代码,在 timeit模块中的源代码,那么你会发现该类的初始化器 .__init__() ,包括以下代码。
# timeit.py
# ...
class Timer:
"""Class for timing execution speed of small code snippets."""
def __init__(
self,
stmt="pass",
setup="pass",
timer=default_timer,
globals=None
):
"""Constructor. See class doc string."""
self.timer = timer
local_ns = {}
global_ns = _globals() if globals is None else globals
# ...
src = template.format(stmt=stmt, setup=setup, init=init)
self.src = src # Save for traceback display
code = compile(src, dummy_src_name, "exec")
exec(code, global_ns, local_ns)
self.inner = local_ns["inner"]
# ...
在突出显示的一行中,对exec() 的调用使用global_ns 和local_ns 作为其全局和局部命名空间来执行用户的代码。
当你为你的用户提供一个工具时,这种使用exec() 的方式是合适的,用户必须提供他们自己的目标代码。这段代码将在用户的机器上运行,所以他们将负责保证输入的代码可以安全运行。
另一个使用exec() 来运行以字符串形式出现的代码的例子是 doctest模块。这个模块检查你的文档串,寻找看起来像 Python交互式会话的文本。如果doctest 找到任何类似于交互式会话的文本,那么它就将该文本作为 Python 代码执行,以检查它是否按预期工作。
例如,假设你有以下函数,用于将两个数字相加。
# calculations.py
def add(a, b):
"""Return the sum of two numbers.
Tests:
>>> add(5, 6)
11
>>> add(2.3, 5.4)
7.7
>>> add("2", 3)
Traceback (most recent call last):
TypeError: numeric type expected for "a" and "b"
"""
if not (isinstance(a, (int, float)) and isinstance(b, (int, float))):
raise TypeError('numeric type expected for "a" and "b"')
return a + b
# ...
在这个代码片断中,add() 定义了一个带有几个测试的 docstring,以检查该函数应该如何工作。注意,这些测试代表了在一个假设的交互式会话中使用有效和无效的参数类型对add() 的调用。
一旦你在文档串中有了这些交互式测试和它们的预期输出,那么你就可以使用doctest 来运行它们,并检查它们是否发出了预期的结果。
注意:doctest模块提供了一个惊人的、有用的工具,你可以在编写代码时使用它来测试你的代码。
转到你的命令行,在包含你的calculations.py 文件的目录中运行以下命令。
$ python -m doctest calculations.py
如果所有的测试都按预期进行,这个命令就不会发出任何输出。如果至少有一个测试失败,那么你会得到一个异常,指出问题所在。为了确认这一点,你可以在函数的文件串中改变一个预期的输出,然后再次运行上述命令。
doctest 模块使用exec() 来执行任何交互式的嵌入 docstring 的代码,你可以在该模块的源代码中确认。
# doctest.py
class DocTestRunner:
# ...
def __run(self, test, compileflags, out):
# ...
try:
# Don't blink! This is where the user's code gets run.
exec(
compile(example.source, filename, "single", compileflags, True),
test.globs
)
self.debugger.set_continue() # ==== Example Finished ====
exception = None
except KeyboardInterrupt:
# ...
正如你在这个代码片段中确认的那样,用户的代码在一个exec() 的调用中运行,它使用compile() 来编译目标代码。为了运行这段代码,exec() 使用test.globs 作为其globals 的参数。注意,在调用exec() 之前的注释开玩笑地说,这是用户的代码运行的地方。
同样,在exec() 的这个用例中,提供安全代码示例的责任在用户身上。doctest 的维护者并不负责确保对exec() 的调用不会造成任何损害。
值得注意的是,doctest 并不能防止与exec() 相关的安全风险。换句话说,doctest 会运行任何 Python 代码。例如,有人可以修改你的add() 函数,在 docstring 中包含以下代码。
# calculations.py
def add(a, b):
"""Return the sum of two numbers.
Tests:
>>> import os; os.system("ls -l")
0
"""
if not (isinstance(a, (int, float)) and isinstance(b, (int, float))):
raise TypeError('numeric type expected for "a" and "b"')
return a + b
如果你在这个文件上运行doctest ,那么ls -l 命令将成功运行。在这个例子中,嵌入的命令大部分是无害的。然而,一个恶意的用户可以修改你的文档串并嵌入类似os.system("rm -rf *") 或任何其他危险的命令。
同样,你必须对exec() 和使用这个功能的工具,如doctest ,保持谨慎。在doctest 的特定情况下,只要你知道你嵌入的测试代码来自哪里,这个工具将是相当安全和有用的。
使用Python处理配置文件
另一种可以使用exec() 来运行代码的情况是当你有一个使用有效 Python 语法的配置文件时。你的文件可以定义几个具有特定值的配置参数。然后你可以读取这个文件,用exec() 处理它的内容,建立一个包含所有配置参数及其值的字典对象。
例如,假设你有下面的配置文件,用于你正在开发的一个文本编辑器应用程序。
# settings.conf
font_face = ""
font_size = 10
line_numbers = True
tab_size = 4
auto_indent = True
这个文件有有效的 Python 语法,所以你可以用exec() 来执行它的内容,就像你对一个普通的.py 文件那样。
注意:你会发现有几种比使用exec() 更好、更安全的方法来处理配置文件。
下面的函数读取你的settings.conf 文件并建立一个配置字典。
>>>
>>> from pathlib import Path
>>> def load_config(config_file):
... config_file = Path(config_file)
... code = compile(config_file.read_text(), config_file.name, "exec")
... config_dict = {}
... exec(code, {"__builtins__": {}}, config_dict)
... return config_dict
...
>>> load_config("settings.conf")
{
'font_face': '',
'font_size': 10,
'line_numbers': True,
'tab_size': 4,
'auto_indent': True
}
load_config() 函数接收到一个配置文件的路径。然后,它把目标文件读成文本,并把该文本传给exec() 执行。在exec() 运行期间,该函数将配置参数注入到locals 字典中,随后返回给调用者代码。
注意:本节中的技术可能是exec() 的一个安全用例。在这个例子中,你将有一个应用程序在你的系统上运行,特别是一个文字编辑器。
如果你修改应用程序的配置文件,加入恶意代码,那么你只会损害自己,而你很可能不会这样做。然而,你仍然有可能不小心在应用程序的配置文件中加入潜在的危险代码。所以,如果你不小心,这种技术最终可能是不安全的。
当然,如果你自己编写应用程序,并发布了带有恶意代码的配置文件,那么你将损害整个社区。
这就是了!现在你可以从生成的字典中读取所有的配置参数及其相应的值,并使用这些参数来设置你的编辑器项目。
总结
你已经学会了如何使用内置的 **exec()**函数来执行来自字符串或字节码输入的 Python 代码。这个函数提供了一个执行动态生成的 Python 代码的快速工具。你还学习了如何将与exec() 相关的安全风险降到最低,以及何时可以在你的代码中使用该函数。
在本教程中,你已经学会了:
- 使用 Python 的内置
exec()函数 - 使用 Python 的
exec()来运行基于字符串和编译的代码输入 - 评估并最大限度地减少与之相关的安全风险
exec()
此外,你已经编码了一些实际的例子,帮助你更好地理解何时以及如何在你的 Python 代码中使用exec() 。