Skip to content

Latest commit

 

History

History
626 lines (403 loc) · 37.5 KB

File metadata and controls

626 lines (403 loc) · 37.5 KB

七、高级模块技术

在本章中,我们将介绍一些使用模块和包的更高级技术。特别是,我们将:

  • 检查使用import语句的更不寻常的方式,包括可选导入、本地导入,以及如何通过更改sys.path来调整导入的工作方式
  • 简要检查与导入模块和包相关的一些“陷阱”
  • 看看如何使用 Python 交互式解释器帮助更快地开发模块和包
  • 了解如何在模块或包中使用全局变量
  • 请参见如何配置包
  • 了解如何将数据文件作为 Python 包的一部分。

可选进口

尝试打开 Python 交互解释器并输入以下命令:

import nonexistent_module

解释器将返回以下错误消息:

ImportError: No module named 'nonexistent_module'

这不应该让你感到惊讶;如果您在import语句中输入错误,您甚至可能在自己的程序中看到此错误。

这个错误的有趣之处在于,它不仅仅适用于你打字错误的地方。您还可以使用此选项测试此特定计算机上是否有可用的模块或包,例如:

try:
    import numpy
    has_numpy = True
except ImportError:
    has_numpy = False

然后,您可以使用此命令让程序利用模块(如果模块存在),或者在模块或包不可用时执行其他操作,如:

if has_numpy:
    array = numpy.zeros((num_rows, num_cols), dtype=numpy.int32)
else:
    array = []
    for row in num_rows:
        array.append([])

在这个示例中,我们检查numpy库是否已安装,如果已安装,则使用numpy.zeros()创建二维数组。否则,我们使用列表代替。这允许您的程序利用 NumPy 库(如果已安装)的速度,而在该库不可用时仍能工作(尽管速度较慢)。

注意,这个例子只是虚构的;您可能无法直接使用列表列表而不是 NumPy 数组,并且在不做任何更改的情况下让程序的其余部分工作。但是,如果模块存在,则做一件事,如果模块不存在,则做另一件事的概念保持不变。

使用像这样的可选导入是一种很好的方法,可以让您的模块或包利用其他库,同时在未安装它们的情况下仍能工作。当然,您应该始终在软件包的文档中提到这些可选导入,以便您的用户知道如果安装了这些可选模块或软件包,将会发生什么。

本地进口

第 3 章使用模块和包中,我们引入了全局名称空间的概念,并展示了import语句如何将导入的模块或包的名称添加到全局名称空间中。这种描述实际上有点过于简单。事实上,import语句将导入的模块或包添加到当前的命名空间中,该命名空间可能是全局命名空间,也可能不是全局命名空间。

在 Python 中,有两个名称空间:全局名称空间和本地名称空间。全局命名空间是存储源文件中所有顶级定义的位置。例如,考虑下面的 Python 模块:

import random
import string

def set_length(length):
    global _length
    _length = length

def make_name():
    global _length

    letters = []
    for i in range(length):
        letters.append(random.choice(string.letters))
    return "".join(letters)

当您导入此 Python 模块时,您将在全局名称空间中添加四个条目:randomstringset_lengthmake_name

全局名称空间中还有其他几个条目,由 Python 解释器自动添加。我们暂时忽略这些。

如果您然后调用set_length()函数,则此函数顶部的global语句将向模块的全局名称空间添加另一个条目,称为_lengthmake_name()函数还包括一个global语句,允许它在生成随机名称时引用全局_length值。

到现在为止,一直都还不错。不太明显的是,在每个函数中,都有第二个名称空间,称为本地名称空间,它包含所有变量和其他非全局定义。在make_name()函数中,letters列表以及for语句使用的变量i都是本地变量,它们只存在于本地名称空间中,当函数退出时它们的值丢失。

本地名称空间不仅仅用于本地变量:您也可以将其用于本地导入。例如,考虑以下功能:

def delete_backups(dir):
    import os
    import os.path
    for filename in os.listdir(dir):
        if filename.endswith(".bak"):
            remove(os.path.join(dir, filename))

注意和os.path模块是如何在函数中导入*,而不是在模块或其他源文件的顶部。因为这些模块是在函数中导入的,osos.path名称被添加到本地名称空间而不是全局名称空间。*

在大多数情况下,您应该避免使用本地导入:将所有import语句放在源文件的顶部附近(从而使所有导入语句都是全局的),这样可以更容易地一目了然地看到源文件所依赖的模块。然而,在两种情况下,本地进口可能是有用的:

  1. 如果要导入的模块或包特别大或初始化速度较慢,则如果使用本地导入而不是全局导入,则导入模块的速度会更快。导入模块时的延迟仅在调用函数时显示。如果只在某些情况下调用该函数,则这可能特别有用。
  2. 本地导入是避免循环依赖的好方法。如果模块 A 依赖于模块 B,模块 B 依赖于模块 A,那么如果两组导入都是全局的,那么您的程序将崩溃。但是,将一组导入更改为本地导入将打破相互依赖关系,因为在调用函数之前不会进行导入。

一般来说,您应该坚持全局导入,尽管本地导入在这些特殊情况下非常有用。

使用 sys.path 调整导入

当您使用import命令时,Python 解释器必须搜索要导入的模块或包。它通过模块搜索路径进行搜索,该路径是可以找到模块或包的各种目录的列表。模块搜索路径存储在sys.path中,Python 解释器将逐个检查此列表中的目录,直到找到所需的模块或包。

Python 解释器启动时,将使用以下目录初始化模块搜索路径:

  • 包含当前正在执行的脚本的目录,如果在终端窗口中运行 Python 交互式解释器,则为当前目录

  • PYTHONPATH环境变量中列出的任何目录

  • The contents of the interpreter's site-packages directory, including any modules referred to by path configuration files within the site-packages directory

    site-packages目录用于存放您安装的各种第三方模块和软件包。例如,如果您使用 Python 包管理器pip来安装 Python 模块或包,则该模块或包通常会放在site-packages目录中。

  • 包含构成 Python 标准库的各种模块和包的许多目录

这些目录出现在sys.path中的顺序很重要,因为一旦找到具有所需名称的模块或包,搜索就会停止。

如果愿意,您可以打印模块搜索路径的内容,尽管列表可能很长,而且很难理解,因为通常有许多目录包含 Python 标准库的各个部分以及您可能安装的任何第三方软件包使用的其他目录:

>>> import sys
>>> print(sys.path)
['', '/usr/local/lib/python3.3/site-packages', '/Library/Frameworks/SQLite3.framework/Versions/B/Python/3.3', '/Library/Python/3.3/site-packages/numpy-override', '/Library/Python/3.3/site-packages/pip-1.5.6-py3.3.egg', '/usr/local/lib/python3.3.zip', '/usr/local/lib/python3.3', '/usr/local/lib/python3.3/plat-darwin', '/usr/local/lib/python3.3/lib-dynload', '/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3', '/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/plat-darwin']

需要记住的重要一点是,此列表将按顺序搜索*,直到找到匹配项。一旦找到具有所需名称的模块或包,搜索就会停止。*

现在,sys.path不仅仅是一个只读列表。如果更改此列表,例如通过添加新目录,则将更改 Python 解释器搜索模块的位置。

实际上,Python 解释器中内置了一些模块;这些始终直接导入,忽略模块搜索路径。要查看 Python 解释器内置了哪些模块,可以执行以下命令:

import sys
print(sys.builtin_module_names)

如果您尝试导入其中一个模块,则无论您对模块搜索路径做了什么,都将始终使用内置版本。

虽然您可以对sys.path进行任何更改,例如删除或重新排列此列表的内容,但最常见的用途是向列表中添加条目。例如,您可能希望将创建的各种模块和包存储在一个特殊的目录中,然后可以从任何需要它的 Python 程序访问该目录。例如,假设您在/usr/local/shared-python-libs有一个目录,其中包含您编写的几个模块和包,您希望在许多不同的 Python 程序中使用这些模块和包。在该目录中,假设您有一个名为utils.py的模块和一个名为approxnums的包,您希望在程序中使用它。虽然简单的import utils会因ImportError而失败,但您可以通过以下方式将shared-python-libs目录的内容提供给您的程序:

import sys
sys.path.append("/usr/local/shared-python-libs")
import utils, approxnums

提示

您可能想知道为什么不能将共享模块和包存储在site-packages目录中。这有两个原因:第一,因为site-packages目录通常受到保护,并且只能由管理员写入,这使得创建和修改存储在该目录中的文件非常困难。第二个原因是,您可能希望将自己的共享模块与已安装的其他第三方模块分开。

在前面的示例中,我们修改了sys.path,将shared-python-libs目录追加到此列表的末尾。在工作时,请记住模块搜索路径是按顺序搜索的*。如果模块搜索路径utils.py上的任何目录中有任何其他模块,则将导入该模块,而不是导入您的shared-python-libs目录中的模块。因此,您通常会按照以下方式修改sys.path,而不是追加:*

sys.path.insert(1, "/usr/local/shared-python-libs")

注意我们使用insert(1, ...)而不是insert(0, ...)。这具有将新目录添加为sys.path中的第二个条目的效果。由于模块搜索路径中的第一个条目通常是包含当前正在执行的脚本的目录,因此添加新目录作为第二个条目意味着将首先搜索程序的目录。这有助于避免在程序目录中定义模块并发现正在导入具有相同名称的不同模块时出现混淆错误。因此,在向sys.path添加目录时使用insert(1, ...)是一种很好的做法。

注意,与任何其他技术一样,修改sys.path也可能被滥用。如果您的可重用模块或包修改了sys.path,代码的用户可能会被显示的细微错误所迷惑,因为您更改了模块搜索路径。作为一般规则,您应该只更改主程序中的模块搜索路径,而不是可重用模块,并且始终清楚地记录您所做的操作,以便不会出现意外情况。

进口戈查斯

虽然模块和包非常有用,但有时 Python 的import机制会给您带来微妙的问题,这些问题可能需要很长时间才能解决。在本节中,我们将讨论在使用模块和包时可能遇到的一些更常见的问题。

为您的模块或包使用现有名称

想象一下您正在编写一个利用 Python 标准库的程序。例如,您可以使用random模块执行以下操作:

import random
print(random.choice(["yes", "no"]))

您的程序工作正常,直到您确定主脚本中的数学函数太多,并对其进行重构以将这些函数移动到单独的模块中。您决定调用此模块math.py,并将其存储在主程序目录中。执行此操作后,前面的代码将立即崩溃,并出现以下错误:

Traceback (most recent call last):
 File "main.py", line 5, in <module>
 import random
 File "/Library/Frameworks/Python.framework/Versions/3.3/lib/python3.3/random.py", line 41, in <module>
 from math import log as _log, exp as _exp, pi as _pi, e as _e, ceil as _ceil
ImportError: cannot import name log

这到底是怎么回事?即使您没有对其进行更改,但工作正常的代码现在也会崩溃。更糟糕的是,回溯显示程序在从 Python 标准库导入模块时崩溃!

要理解这里发生的事情,您需要记住,默认情况下,模块搜索路径包括当前程序的目录,作为指向 Python 标准库各个部分的其他条目之前的第一个条目。通过在程序中创建一个名为math.py的新模块,Python 解释器无法从 Python 标准库加载math.py模块。这不仅适用于您编写的代码,而且适用于模块搜索路径上可能试图从 Python 标准库加载此模块的任何模块或包。在本例中,random模块发生故障,但可能是依赖于math库的任何模块。

这被称为名称屏蔽,是一个特别隐蔽的问题。为了避免这种情况,在选择程序中顶级模块和包的名称时应始终小心,以确保它们不会屏蔽 Python 标准库中的模块,无论您是否使用该模块。

避免名称屏蔽的一种简单方法是使用包来组织程序中编写的模块和包。例如,您可以创建一个名为lib的顶级包,并在lib包中创建各种模块和包。由于 Python 标准库中没有名为lib的模块或包,因此无论您为lib包中的模块和包选择什么名称,您都不会屏蔽标准库模块。

以模块或包命名 Python 脚本

当您有一个与 Python 标准库中的模块同名的 Python 脚本时,可能会出现一个更微妙的名称屏蔽示例。例如,假设您试图弄清楚re模块(是如何工作的 https://docs.python.org/3.3/library/re.html 工程。如果您以前没有使用过正则表达式,那么这个模块可能会有点混乱,因此您可能会决定编写一个简单的测试脚本来了解它是如何工作的。此测试脚本可能包括以下代码:

import re

pattern = input("Regular Expression: ")
s = input("String: ")

results = re.search(pattern, s)

print(results.group(), results.span())

此程序可能会帮助您了解re模块的功能,但如果您以re.py的名称保存此脚本,则在运行程序时会出现一个神秘的错误:

$ python re.py
Regular Expression: [0-9]+
String: test123abc
Traceback (most recent call last):
...
File "./re.py", line 9, in <module>
 results = re.search(pattern, s)
AttributeError: 'module' object has no attribute 'search'

你能搞清楚这是怎么回事吗?答案再次出现在模块搜索路径中。脚本的名称re.py屏蔽了 Python 标准库中的re模块,因此当您的程序尝试导入re模块时,它实际上会自行加载。您在这里看到一个AttributeError,因为脚本成功地将自身加载为模块,但该模块没有您期望的search()功能。

将脚本本身作为模块导入也可能导致意外问题;我们很快就会看到这一点。

这个问题的解决方案很简单:不要将 Python 标准库模块的名称用于脚本。相反,将您的测试脚本称为类似于re_test.py

将包目录添加到 sys.path

一个常见的陷阱是在sys.path中添加一个包目录。让我们来看看当你这样做时会发生什么。

创建一个存放测试程序的目录,并在此主目录中创建一个名为package的子目录。然后,在package目录中创建一个空包初始化(__init__.py文件。另外,在同一目录中创建一个名为module.py的模块。然后,将以下内容添加到module.py文件中:

print("### Initializing module.py ###")

当导入模块时,它会打印一条消息。接下来,在最上面的目录中创建一个名为good_imports.py的 Python 源文件,并在该文件中输入以下 Python 代码:

print("Calling import package.module...")
import package.module
print("Calling import package.module as module...")
import package.module as module
print("Calling from package import module...")
from package import module

保存此文件后,打开终端或命令行窗口,使用cd命令将当前目录设置为最外层目录(包含good_imports.py脚本的目录),并键入python good_imports.py运行此程序。您应该看到以下输出:

$ python good_imports.py
Calling import package.module...
### Initializing module.py ###
Calling import package.module as module...
Calling from package import module...

如您所见,第一条import语句加载了模块,这导致### Initializing module.py ###消息被打印出来。对于后续的import语句,没有进行初始化,而是使用了已导入的模块副本。这是我们想要的行为,因为它确保每个模块只有一个副本。这对于那些将信息保存在全局变量中的模块非常重要,因为在其全局变量中具有不同值的模块的不同副本可能会导致各种奇怪和混乱的行为。

不幸的是,如果我们将一个包或包的子目录添加到sys.path中,这正是我们可以得到的结果。要查看此问题的实际情况,请创建一个名为bad_imports.py的新顶级脚本,并在此文件中输入以下内容:

import os.path
import sys

cur_dir = os.path.abspath(os.path.dirname(__file__))
package_dir = os.path.join(cur_dir, "package")

sys.path.insert(1, package_dir)

print("Calling import package.module as module...")
import package.module as module
print("Calling import module...")
import module

该程序将package_dir设置为package目录的完整目录路径,然后将该目录添加到sys.path。然后它做出两个独立的import语句,一个从名为package的包中导入module,另一个直接导入module。这两个import语句都可以工作,因为模块可以通过两种方式访问。但是,结果并非您所期望的:

$ python bad_imports.py
Calling import package.module as module...
### Initializing module.py ###
Calling import module...
### Initializing module.py ###

如您所见,模块被导入两次,一次为package.module,另一次为module。最后,您将得到模块的两个独立副本,这两个副本都已初始化,并在 Python 系统中显示为两个不同的模块。

拥有一个模块的两个副本可能会导致各种微妙的错误和问题。这就是为什么您不应该将 Python 包或 Python 包的子目录直接添加到sys.path中。

提示

当然,可以将包含包的目录添加到sys.path中;只是不要添加包目录本身。

执行并导入同一模块

如果执行 Python 源文件,然后像导入模块一样导入同一个文件,则可能会出现双重导入问题的另一个更微妙的例子。要了解其工作原理,请创建一个目录来保存一个新的示例程序,并在此目录中创建一个名为test.py的新 Python 源文件。然后,在此文件中输入以下内容:

import helpers

def do_something(n):
    return n * 2

if __name__ == "__main__":
    helpers.run_test()

当该文件作为脚本运行时,它调用helpers.run_test()函数开始运行测试。该文件还定义了一个函数do_something(),它执行一些有用的功能。现在,在名为helpers.py的同一目录中创建第二个 Python 源文件,并在该文件中输入以下内容:

import test

def run_test():
    print(test.do_something(10))

如您所见,helpers.py模块正在导入test.py作为一个模块,然后调用do_something()函数作为运行测试的一部分。换句话说,即使test.py是作为脚本执行的,它也是作为该脚本执行的一部分作为模块(间接)导入的。

让我们看看运行此程序时会发生什么:

$ python test.py
20

到现在为止,一直都还不错。该程序正在运行,尽管有复杂的模块导入,但似乎仍在运行。但是让我们仔细看看;在test.py脚本顶部添加以下语句:

print("Initializing test.py")

在前面的示例中,我们使用print()语句来显示加载模块的时间。这为模块提供了自我初始化的机会,我们希望只看到初始化发生一次,因为内存中每个模块应该只有一个副本。

然而,在这种情况下,情况并非如此。请尝试再次运行该程序:

$ python test.py
Initializing test.py
Initializing test.py
20

如您所见,模块被初始化两次——一次是作为脚本运行,另一次是由helpers.py导入模块。

要避免此问题,请确保您编写的所有脚本都仅用作脚本。将任何其他代码(如我们前面示例中的do_something()函数)保留在脚本之外,这样您就不需要导入它们了。

提示

请注意,这并不意味着您不能像第 3 章使用模块和包中所述,使用变色龙模块作为模块和脚本。请注意,您执行的脚本只使用模块本身中定义的函数。如果您开始从同一个包导入其他模块,您可能应该将所有功能移动到另一个模块中,然后将其导入到脚本中,而不是将它们放在同一个文件中。

在 Python 交互式解释器中使用模块和包

除了从 Python 脚本调用模块和包外,直接从 Python 交互式解释器调用它们通常也很有用。这是一种将快速应用程序开发RAD)技术用于 Python 编程的好方法:您对 Python 模块或包进行某种更改,并通过从 Python 交互式解释器调用该模块或包,立即看到更改的结果。

但是,有一些限制和问题需要注意。让我们仔细研究一下如何使用交互式解释器加速模块和包的开发;我们还将看到哪里有一种不同的方法更适合您。

首先创建一个名为stringutils.py的新 Python 模块,并在此文件中输入以下代码:

import re

def extract_numbers(s):
    pattern = r'[+-]?\d+(?:\.\d+)?'
    numbers = []
    for match in re.finditer(pattern, s):
        number = s[match.start:match.end+1]
        numbers.append(number)
    return numbers

这个模块代表我们第一次尝试编写一个函数来从字符串中提取所有数字。请注意,如果您试图使用它,extract_numbers()函数将崩溃,但它尚未工作。它也不是特别有效(更简单的方法是使用re.findall()函数)。但是我们故意使用这段代码来展示如何将快速应用程序开发技术应用于 Python 模块,请耐心等待。

此函数使用re(正则表达式)模块查找字符串中与给定表达式模式匹配的部分。复杂的pattern字符串用于匹配一个数字,包括前面可选的+-,任意数字,以及末尾可选的小数部分。

使用re.finditer()函数,我们找到字符串中与正则表达式模式匹配的部分。然后,我们提取字符串的每个匹配部分,并将结果附加到numbers列表,然后返回给调用者。

关于我们的功能应该做什么,就到此为止。让我们测试一下。

打开终端或命令行窗口,使用cd命令切换到stringutils.py模块所在目录。然后,键入python启动 Python 交互式解释器。当 Python 命令提示出现时,请尝试输入以下内容:

>>> import stringutils
>>> print(stringutils.extract_numbers("Tes1t 123.543 -10.6 5"))
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "./stringutils.py", line 7, in extract_numbers
 number = s[match.start:match.end+1]
TypeError: unsupported operand type(s) for +: 'builtin_function_or_method' and 'int'

正如你所看到的,我们的模块还不能工作,我们有一个 bug。仔细看,我们可以看到问题出现在stringutils.py模块的第 7 行:

        number = s[match.start:match.end+1]

错误消息表明您正在尝试将内置函数(在本例中为match.end)添加到一个数字(1),这当然不起作用。match.startmatch.end值本应是数字开头和结尾字符串的索引,但快速查看re模块的文档可以发现match.startmatch.end是函数,而不是简单的数字,因此我们需要调用这些函数来获得我们想要的值。这样做很容易;只需编辑文件的第 7 行,如下所示:

        number = s[match.start():match.end()+1]

现在我们已经改变了我们的模块,让我们来看看发生了什么。我们将从重新执行print()语句开始,看看这是否有效:

>>> print(stringutils.extract_numbers("Tes1t 123.543 -10.6 5"))

提示

您知道吗,您可以按键盘上的向上箭头键和向下箭头键来浏览之前在 Python 交互式解释器中键入的命令的历史记录?这使您无需重新键入命令;只需使用箭头键选择所需命令,然后按返回执行即可。

您将立即看到与以前相同的错误消息,但没有任何更改。这是因为您将模块导入 Python 解释器;导入模块或包后,它将保存在内存中,并且忽略磁盘上的源文件。

要使更改生效,您需要重新加载模块。为此,请在 Python 解释器中键入以下内容:

import importlib
importlib.reload(stringutils)

提示

如果您使用的是 Python2.x,则不能使用importlib模块。相反,只需键入reload(stringutils)。如果您使用的是 Python 版本 3.3,请使用imp而不是importlib

现在尝试重新执行print()语句:

>>> stringutils.extract_numbers("Hell1o 123.543 -10.6 5 there")
['1o', '123.543 ', '-10.6 ', '5 ']

这比我们现在运行的程序没有崩溃要好得多。然而,我们还需要解决一个问题:当我们提取组成一个数字的字符时,我们提取的字符太多了,因此数字1被返回为1o,依此类推。要解决此问题,请从源文件的第 7 行删除+1

        number = s[match.start():match.end()]

然后,再次重新加载模块并重新执行print()语句。您应该看到以下内容:

['1', '123.543', '-10.6', '5']

完美的如果您愿意,您可以使用float()函数将这些字符串转换为浮点数,但是出于我们的目的,这个模块现在已经完成。

让我们后退一步,回顾一下我们所做的。我们有一个有错误的模块,并使用 Python 交互式解释器帮助识别和修复这些问题。我们反复测试我们的程序,发现一个错误,并修复它,使用 RAD 方法快速发现并纠正模块中的错误。

在开发模块和软件包时,在交互式解释器中测试它们,以便在开发过程中发现并解决问题,这通常很有帮助。您只需记住,每次更改 Python 源文件时,都需要调用importlib.reload()来重新加载受影响的模块或包。

以这种方式使用 Python 交互式解释器也意味着您有完整的 Python 系统可供测试。例如,您可以使用 Python 标准库中的pprint模块漂亮地打印一个复杂的字典或列表,这样您就可以轻松地查看某个函数返回的信息。

然而,在importlib.reload()过程中存在一些限制:

  • 假设您有两个模块,A 和 B。模块 A 使用from B import...语句从模块 B 加载功能。如果您随后更改模块 B,则更改后的功能将不会被模块 A 使用,除非您也重新加载该模块。

  • If your module crashes while initializing itself, it can end up in a strange state. For example, imagine that your module includes the following top-level code, which is supposed to initialize a list of customers:

    customers = []
    customers.append("Mike Wallis")
    cusotmers.append("John Smith")

    将导入此模块,但由于变量名拼写错误,它将在初始化期间引发异常。如果发生这种情况,您需要首先在 Python 交互解释器中使用import命令使模块可用,然后使用imp.reload()加载更新的源代码。

  • 因为您必须自己键入命令或从 Python 命令历史记录中选择一个命令,所以反复运行相同的代码可能会变得单调乏味,尤其是当您的测试涉及多个步骤时。在使用交互式解释器时,很容易遗漏一步。

出于这些原因,最好使用交互式解释器来解决特定的问题或帮助您快速开发特定的小代码段。当测试变得复杂或必须使用多个模块时,自定义编写的脚本会更好地工作。

处理全局变量

我们已经了解了如何使用全局变量在模块内的不同函数之间共享信息。我们已经了解了如何将全局变量定义为模块中的顶级变量,从而在第一次导入模块时对其进行初始化,我们还了解了如何在函数中使用global语句来允许该函数访问和更改全局变量的值。

在本节中,我们将在此基础上学习如何在模块之间共享全局变量*。创建包时,通常需要定义可由该包中的任何模块访问或更改的变量。有时,您还需要在包外为 Python 代码提供一个变量。让我们来看看如何做到这一点。*

创建一个名为globtest的新目录,并在该目录中创建一个空的包初始化文件,使其成为 Python 包。然后,在该目录中创建一个名为globals.py的文件,并在该文件中输入以下内容:

language = None
currency = None

在这个模块中,我们定义了两个要在包中使用的全局变量,并为每个变量指定了一个默认值None。现在让我们在另一个模块中使用这些全局变量。

在名为test.pyglobtest目录中创建另一个文件,并在该文件中输入以下内容:

from . import globals

def test():
    globals.language = "EN"
    globals.currency = "USD"
    print(globals.language, globals.currency)

要测试您的程序,请打开终端或命令行窗口,使用cd命令移动到包含globtest包的目录,然后键入python启动 Python 交互式解释器。然后,尝试输入以下内容:

>>> from globtest import test
>>> test.test()
EN USD

正如您所看到的,我们已经成功地设置了languagecurrency全局值,这些值存储在我们的globals模块中,然后再次检索这些值并打印出来。因为我们将这些全局变量存储在一个单独的模块中,所以您可以在当前包中的任何位置,甚至在导入包的其他代码中检索或更改这些全局变量。使用单独的模块保存包的全局变量是在包中管理全局变量的一种很好的方法。

但是,有一件事需要注意:为了在模块之间共享全局变量,必须导入包含该全局变量的模块,而不是变量本身。例如,以下操作不起作用:

from .test import language

此语句所做的是将language变量的副本导入当前模块的全局名称空间,而不是原始全局名称空间。这意味着全局变量不会与其他模块共享。对于要在模块之间共享的变量,您需要导入globals模块,而不是其中的变量。

包装配置

当您开发更复杂的模块和包时,您经常会发现您的代码需要以某种方式配置才能使用。例如,假设您正在编写一个使用数据库的包。要做到这一点,您的包需要知道要使用哪个数据库引擎、数据库的名称以及用于访问该数据库的用户名和密码。

您可以将这些信息硬连接到程序的源代码中,但这样做是一个非常糟糕的主意,原因有两个:

  • 不同的计算机和不同的操作系统将使用不同的数据库设置。由于用于访问数据库的信息因计算机而异,因此任何想要使用您的软件包的人都必须直接编辑源代码以输入正确的数据库详细信息,然后才能运行软件包。
  • 用于访问数据库的用户名和密码是高度敏感的信息。如果您与其他人共享包,甚至只是将包的源代码副本存储在公共存储库(如 GitHub)上,那么其他人可以发现您的数据库访问凭据。这是一个巨大的安全风险。

这些数据库访问凭据是包配置的一个示例—您的包在运行之前需要的信息,但您不希望将其构建到包的源代码中。

如果您正在构建一个应用程序而不是一个独立的模块或包,那么您的配置任务要简单得多。Python 标准库中有一些模块可以帮助进行配置,例如,configparsershlexjson。使用这些模块,您可以将配置设置存储在磁盘上的文件中,最终用户可以编辑该文件。当程序启动时,您将这些设置加载到内存中,并根据需要访问它们。由于配置设置存储在应用程序的外部,因此用户不必编辑源代码来配置程序,并且如果发布或共享源代码,您也不会公开敏感信息。

然而,当编写模块和包时,基于文件的配置方法就不那么方便了。没有明显的地方可以存储包的配置文件,并且在特定位置需要配置文件会使您的模块或包更难作为不同程序的一部分重用。

相反,模块或包的配置通常通过向模块或包的初始化函数提供参数来完成。我们在上一章中看到了一个例子,quantities包要求您在初始化包时提供一个locale值:

quantities.init("us")

这将配置作业传递回周围的应用程序;应用程序可以使用配置文件或它喜欢的任何其他配置方案,并且是应用程序在初始化包时提供包的配置设置:

Package configuration

这个使包开发人员的工作变得更容易,因为包需要做的就是记住已经给出的设置。

尽管quantities包只使用了一个配置设置(语言环境的名称),但包通常使用许多设置。为包提供配置设置的一种非常方便的方法是使用 Python 字典。例如:

mypackage.init({'log_errors'  : True,
                'db_password' : "test123",
                ...})

以这种方式使用字典可以很容易地为包的配置设置支持默认值。下面的 Python 代码片段显示了包的init()函数如何接受配置设置、提供默认值并将设置存储在全局变量中,以便在需要时可以访问它:

def init(settings):
    global config

    config = {}
    config['log_errors']  = settings.get("log_errors",  False)
    config['db_password'] = settings.get("db_password", "")
    ...

以这种方式使用dict.get()时,如果已提供设置,则检索该设置,如果未指定设置,则提供要使用的默认值。这是在 Python 模块或包中处理配置的理想方法,使模块或包的用户可以根据需要简单地进行配置,同时仍将配置设置的存储方式和位置的详细信息留给应用程序。

包装数据

一个包可能不仅仅包含 Python 源文件。有时,您可能还需要包含其他类型的文件。例如,一个包可能包括一个或多个图像文件、一个包含美国所有邮政编码列表的大型文本文件,或者您可能需要的任何其他类型的数据。如果可以在文件中存储某些内容,则可以将此文件作为 Python 包的一部分。

通常,您会将包数据放在包目录中的一个单独的子目录中。要访问这些文件,您的包需要知道在哪里可以找到此子目录。虽然可以将此目录的位置硬连接到包中,但如果要重用或移动包,则此操作将不起作用。这也不是必需的,因为您可以使用以下代码轻松找到模块所在的目录:

cur_dir = os.path.abspath(os.path.dirname(__file__))

这将为您提供包含当前模块的目录的完整路径。使用os.path.join()功能,您可以访问保存数据文件的子目录,并以通常的方式打开它们:

phone_numbers = []
cur_dir = os.path.abspath(os.path.dirname(__file__))
file = open(os.path.join(cur_dir, "data", "phone_numbers.txt"))
for line in file:
    phone_numbers.append(line.strip())
file.close()

在包中包含数据文件的好处在于,数据文件实际上是包源代码的一部分。当您共享软件包或将其上载到源代码存储库(如 GitHub)时,数据文件将自动与软件包的其余部分一起包含。这使得跟踪包使用的数据文件变得更加容易。

总结

在本章中,我们研究了在 Python 中使用模块和包的一些更高级的方面。我们了解了如何使用try..except语句来实现可选导入,以及如何将import语句放置在函数中,以便仅在执行该函数时导入模块。然后,我们了解了模块搜索路径,以及如何修改sys.path以改变 Python 解释器查找模块和包的方式。

然后我们看了一些与模块和包的使用相关的问题。我们学习了名称掩蔽,您可以使用与 Python 标准库中的模块或包相同的名称定义模块或包,这可能会导致意外故障。我们研究了赋予 Python 脚本与标准库模块相同的名称如何也会导致名称屏蔽问题,以及向sys.path添加包目录或子目录如何导致模块加载两次,从而导致该模块中全局变量出现微妙的问题。我们看到了如何执行一个模块,然后导入它也会导致该模块被加载两次,这可能再次导致问题。

接下来,我们研究了如何将 Python 交互式解释器作为一种快速应用程序开发(RAD)工具来快速查找和修复模块和包中的问题,以及importib.reload()命令如何允许您在更改底层源代码后重新加载模块

通过学习如何定义整个包中使用的全局变量、如何处理包配置以及如何在包中存储和访问数据文件,我们完成了对高级模块技术的调查。

在下一章中,我们将介绍一些测试、部署和共享 Python 模块和包的方法。