Ansible 和 Python 3

ansible-core 代码运行 Python 3(有关特定版本,请查看 控制节点要求 )。 ansible-core 和 Ansible 集合的贡献者应该了解本文档中的技巧,以便他们可以编写在与 Ansible 其他版本相同的 Python 版本上运行的代码。

我们确实有一些考虑因素,具体取决于 Ansible 代码的类型

  1. 控制节点上的代码 - 在您调用 /usr/bin/ansible 的机器上运行的代码,只需要支持控制节点的 Python 版本。

  2. 模块 - Ansible 传输到并在受管机器上调用的代码。模块需要支持“受管节点” Python 版本,但也有一些例外。

  3. 共享 module_utils 代码 - 模块用来执行任务的通用代码,有时也由控制节点上的代码使用。共享 module_utils 代码需要支持与模块相同的 Python 版本范围。

但是,这三种类型的代码不使用相同的字符串策略。如果您正在开发一个模块或一些 module_utils 代码,请务必仔细阅读有关字符串策略的部分。

Python 3.x 和 Python 2.x 的最低版本

有关支持的特定版本,请参阅 控制节点要求受管节点要求

您的自定义模块可以支持您想要的任何版本的 Python(或其他语言),但上述要求适用于为 Ansible 项目贡献的代码。

开发支持 Python 2 和 Python 3 的 Ansible 代码

学习编写支持 Python 2 和 Python 3 的代码的最佳起点是 Lennart Regebro 的书:移植到 Python 3。这本书描述了移植到 Python 3 的几种策略。我们正在使用的一种是 从单个代码库支持 Python 2 和 Python 3

理解 Python 2 和 Python 3 中的字符串

Python 2 和 Python 3 对字符串的处理方式不同,因此在编写支持 Python 3 的代码时,您必须决定使用哪种字符串模型。字符串可以是字节数组(如 C 中的字节数组),也可以是文本数组。文本是我们认为的字母、数字、符号以及少量不可打印的“符号”(控制代码)。

在 Python 2 中,这两种类型的 (str 用于字节,unicode 用于文本) 通常可以互换使用。仅处理 ASCII 字符时,可以自动组合、比较和将字符串从一种类型转换为另一种类型。当引入非 ASCII 字符时,Python 2 由于不知道非 ASCII 字符应该使用哪种编码而开始抛出异常。

Python 3 通过使字节 (bytes) 和文本 (str) 之间的区别更加严格来改变这种行为。当尝试组合和比较这两种类型时,Python 3 将抛出异常。程序员必须显式地从一种类型转换为另一种类型才能混合每种类型的值。

在 Python 3 中,程序员可以立即清楚地看到代码何时不恰当地混合了字节类型和文本类型,而在 Python 2 中,混合这些类型的代码可能会正常工作,直到用户通过输入非 ASCII 输入导致异常。Python 3 强制程序员主动为程序中使用字符串定义一种策略,以便他们不会无意中混合文本和字节字符串。

Ansible 在控制节点上的代码中使用不同的策略来处理字符串,在 :ref: 模块 <module_string_strategy> 中,以及在 module_utils 代码中。

控制节点字符串策略:Unicode Sandwich

直到最近, ansible-core 才支持 Python 2.x 并遵循这种策略,称为 Unicode Sandwich(以 Python 2 的 unicode 文本类型命名)。对于 Unicode Sandwich,我们知道在我们的代码与外部世界(例如,文件和网络 IO、环境变量以及一些库调用)的边界上,我们将收到字节。我们需要将这些字节转换为文本并在代码的内部部分使用它们。当我们必须将这些字符串发送回外部世界时,我们首先将文本转换回字节。为了形象地说明这一点,想象一个“三明治”,它由上下两层字节组成,中间一层是转换,中心是所有文本类型。

为了保持兼容性,您会看到我们开发了一堆自定义函数 (to_text/to_bytes/to_native),虽然 Python 2 已经不再是问题,但我们将继续使用它们,因为它们适用于其他一些使处理 unicode 变得很麻烦的场景。

虽然我们不再使用它,但下面的文档对于那些仍在开发需要同时支持 Python 2 和 3 的模块的人来说仍然有用。

Unicode Sandwich 通用边界:在控制节点代码中将字节转换为文本的位置

这是一个不完整的列表,列出了在使用 Unicode Sandwich 字符串策略时必须在其中进行字节转换的地方。它并不详尽,但它让您了解需要注意哪些问题。

读取和写入文件

在 Python 2 中,从文件中读取会生成字节。在 Python 3 中,它可以生成文本。为了使代码可移植到两者,我们不利用 Python 3 生成文本的能力,而是显式地自己进行转换。例如

from ansible.module_utils.common.text.converters import to_text

with open('filename-with-utf8-data.txt', 'rb') as my_file:
    b_data = my_file.read()
    try:
        data = to_text(b_data, errors='surrogate_or_strict')
    except UnicodeError:
        # Handle the exception gracefully -- usually by displaying a good
        # user-centric error message that can be traced back to this piece
        # of code.
        pass

注意

Ansible 的大部分内容都假设所有编码文本都是 UTF-8。如果将来需要支持其他编码,我们可能会进行更改,但目前可以安全地假设字节是 UTF-8 编码的。

写入文件是相反的过程。

from ansible.module_utils.common.text.converters import to_bytes

with open('filename.txt', 'wb') as my_file:
    my_file.write(to_bytes(some_text_string))

请注意,我们不需要在这里捕获 UnicodeError,因为我们正在转换为 UTF-8,而 Python 中的所有文本字符串都可以转换回 UTF-8。

文件系统交互

处理文件名通常涉及降级为字节,因为在类 UNIX 系统中,文件名是字节。在 Python 2 中,如果我们将文本字符串传递给这些函数,文本字符串将在函数内部转换为字节字符串,如果存在非 ASCII 字符,则会发生回溯。在 Python 3 中,只有当文本字符串无法在当前区域设置中解码时才会发生回溯,但明确且在两个版本中都可用的代码仍然是好的。

import os.path

from ansible.module_utils.common.text.converters import to_bytes

filename = u'/var/tmp/くらとみ.txt'
f = open(to_bytes(filename), 'wb')
mtime = os.path.getmtime(to_bytes(filename))
b_filename = os.path.expandvars(to_bytes(filename))
if os.path.exists(to_bytes(filename)):
    pass

当您只是操作文件名作为字符串而没有与文件系统(或与文件系统交互的 C 库)对话时,您通常可以避免转换为字节。

import os.path

os.path.join(u'/var/tmp/café', u'くらとみ')
os.path.split(u'/var/tmp/café/くらとみ')

另一方面,如果代码需要操作文件名并与文件系统对话,那么立即转换为字节并在字节中操作可能更方便。

警告

确保传递给函数的所有变量类型相同。如果您使用的是像 os.path.join() 这样的函数,它接受多个字符串并将它们组合使用,您需要确保所有类型都相同(要么全部是字节,要么全部是文本)。混合使用字节和文本会导致回溯。

与其他程序交互

与其他程序的交互通过操作系统和 C 库进行,并对 UNIX 内核定义的事物进行操作。这些接口都是面向字节的,因此 Python 接口也是面向字节的。在 Python 2 和 Python 3 中,都应该将字节字符串传递给 Python 的子进程库,并且应该从子进程库中获得字节字符串作为返回值。

Ansible 控制节点代码中与其他程序交互的主要位置之一是连接插件的 exec_command 方法。这些方法将它们在命令(以及命令参数)中接收到的任何文本字符串转换为字节,并将 stdout 和 stderr 作为字节字符串返回。更高级别的函数(例如操作插件的 _low_level_execute_command)将输出转换为文本字符串。

模块字符串策略:原生字符串

在模块中,我们使用一种称为原生字符串的策略。这使得维护 Ansible 众多模块的社区成员更容易,因为它们不会通过强制模块内部的所有字符串都是文本并在边界之间进行文本和字节的转换来破坏向后兼容性。

原生字符串指的是 Python 在您指定裸字符串文字时使用的类型。

"This is a native string"

在 Python 2 中,它们是字节字符串。在 Python 3 中,它们是文本字符串。模块应该被编码为在 Python 2 中期望字节,在 Python 3 中期望文本。

Module_utils 字符串策略:混合

module_utils 代码中,我们使用混合字符串策略。尽管 Ansible 的 module_utils 代码在很大程度上与模块代码类似,但它的一些部分也由控制节点使用。因此,它需要与模块和控制节点的假设兼容,特别是字符串策略。module_utils 代码试图接受原生字符串作为其函数的输入,并发出原生字符串作为其输出。

module_utils 代码中

  • 函数**必须**接受字符串参数作为文本字符串或字节字符串。

  • 函数可以返回与传递给它们的相同类型的字符串,也可以返回它们运行的 Python 版本的原生字符串类型。

  • 返回字符串的函数**必须**记录它们是返回与传递给它们的相同类型的字符串,还是返回原生字符串。

因此,module-utils 函数通常具有很强的防御性。它们将字符串参数转换为文本(使用 ansible.module_utils.common.text.converters.to_text)在函数的开头,完成它们的工作,然后将返回值转换为原生字符串类型(使用 ansible.module_utils.common.text.converters.to_native)或转换回它们参数接收的字符串类型。

Python 2/Python 3 兼容性的技巧、窍门和习惯用法

使用向前兼容性模板

在所有 Python 文件的开头使用以下模板代码,以确保某些结构在 Python 2 和 Python 3 上的行为相同。

# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type

__metaclass__ = type 使文件定义的所有类成为新式类,而无需显式继承自 object

__future__ 导入执行以下操作

absolute_import:

sys.path 中查找要导入的模块,跳过进行导入的模块所在的目录。如果代码想要使用进行导入的模块所在的目录,则可以使用新的点表示法来实现。

division:

使整数除法始终返回浮点数。如果您需要查找商,请使用 x // y 而不是 x / y

print_function:

print 从关键字更改为函数。

使用 b_ 前缀字节字符串

由于混合文本和字节类型会导致回溯,因此我们希望清楚地了解哪些变量保存文本,哪些变量保存字节。我们通过在保存字节的任何变量前添加 b_ 来做到这一点。例如

filename = u'/var/tmp/café.txt'
b_filename = to_bytes(filename)
with open(b_filename) as f:
    data = f.read()

我们不会给文本字符串添加前缀,因为我们只在边界上操作字节字符串,因此需要字节的变量比文本少。

导入 Ansible 的捆绑 Python six

第三方 Python six 库旨在帮助项目创建既可以在 Python 2 上运行,也可以在 Python 3 上运行的代码。Ansible 在 module_utils 中包含了该库的版本,以便其他模块可以使用它,而无需在远程系统上安装它。要使用它,请像这样导入它。

from ansible.module_utils import six

注意

Ansible 也可以使用系统版本的 six

如果系统版本的 six 版本比 Ansible 捆绑的版本新,Ansible 将使用系统版本的 six。

使用 as 处理异常

为了使代码在 Python 2.6+ 和 Python 3 上正常运行,请使用新的异常捕获语法,该语法使用 as 关键字。

try:
    a = 2/0
except ValueError as e:
    module.fail_json(msg="Tried to divide by zero: %s" % e)

**不要**使用以下语法,因为它会在 Python 3 的所有版本上失败。

try:
    a = 2/0
except ValueError, e:
    module.fail_json(msg="Tried to divide by zero: %s" % e)

更新八进制数

在 Python 2.x 中,可以使用 0755 指定八进制文字。在 Python 3 中,必须使用 0o755 指定八进制。

控制节点代码的字符串格式化

使用 str.format() 以实现 Python 2.6 兼容性

从 Python 2.6 开始,字符串获得了一个名为 format() 的方法来将字符串组合在一起。但是,format() 的一个常用功能直到 Python 2.7 才被添加,因此您需要记住在 Ansible 代码中不要使用它。

# Does not work in Python 2.6!
new_string = "Dear {}, Welcome to {}".format(username, location)

# Use this instead
new_string = "Dear {0}, Welcome to {1}".format(username, location)

上面的两个格式字符串都将 format() 方法的位置参数映射到字符串中。但是,第一个版本在 Python 2.6 中不起作用。始终记住将数字放入占位符中,以便代码与 Python 2.6 兼容。

另请参阅

Python 关于格式字符串的文档

使用百分号格式化字节字符串

在 Python 3.x 中,字节字符串没有 format() 方法。但是,它确实支持旧的百分号格式化。

b_command_line = b'ansible-playbook --become-user %s -K %s' % (user, playbook_file)

注意

在 Python 3.5 中添加的百分号格式化

字节字符串的百分号格式化在 Python 3 的 3.5 版本中被重新添加。这对我们来说不是问题,因为 Python 3.5 是我们的最低版本。但是,如果您碰巧使用 Python 3.4 或更早的版本测试 Ansible 代码,您会发现这里的字节字符串格式化将不起作用。升级到 Python 3.5 进行测试。

另请参阅

Python 关于 百分号格式化 的文档