Scott's Blog

学则不固, 知则不惑

0%

Python 高级导入技巧

这篇文章深入研究了 import 命令以及它的工作原理。

基本导入命令

Python-模块与包 一文中,我们看到过 import 命令,它可以用来导入包和模块。

import 命令有几种形式:

  • import math
  • from math import pi
  • import math as m

对于 import 命令,它不关心 import 的是一个包还是一个模块,因为语法是一样的,只是包在构建的时候有些不同。

一个没有 __init__.py 文件的目录,也会被当做一个包,只是不是普通的包,有时候称其为命名空间包 (namespace packages)。

通常情况下,在一个普通的包中,子模块和子包默认不会导入,除非你在 __init__.py 中有 import 子包和子模块。

举一例,有个叫 world 包的包,其目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
world/

├── africa/
│ ├── __init__.py
│ └── zimbabwe.py

├── europe/
│ ├── __init__.py
│ ├── greece.py
│ ├── norway.py
│ └── spain.py

└── __init__.py

如果你要使用 world 包,若其中没有 __init__.py 是不会包括子包的。

导入一个包会加载包的内容,并创建命名空间,命名空间是一个字典,你可以通过 __dict__ 属性访问到:

1
2
3
>>> import math
>>> math.__dict__["pi"]
3.141592653589793

同样的,全局变量也是一个命名空间,可以通过 globals() 访问。

常见导入问题

假设你现在有一个包,它的目录结构如下所示:

1
2
3
4
structure/

├── files.py
└── structure.py

structure.py 中的代码:

1
2
3
4
5
# structure.py
import files

def main():
pass

运行 python structure.py . 后的输出:

1
2
3
4
$ python structure.py .
Create file: /home/gahjelle/structure/001/structure.py
Create file: /home/gahjelle/structure/001/files.py
Create file: /home/gahjelle/structure/001/__pycache__/files.cpython-38.pyc

当你在 structure.py 需要用到 files 时,如果这两个文件在同一目录,这样是没有问题的。

现在为了满足 Pyinstaller guide 而创建了一个程序入口,你的目录结构变成:

1
2
3
4
5
6
7
structure/

├── structure/
│ ├── files.py
│ └── structure.py

└── cli.py

cli.py 中,你导入了 structure 中的 main 函数:

1
2
3
4
5
6
# cli.py

from structure.structure import main

if __name__ == "__main__":
main()

此时,如果你在 cli.py 所在目录执行 python cli.py structure,则会报错:

1
2
3
4
5
6
Traceback (most recent call last):
File "cli.py", line 1, in <module>
from structure.structure import main
File "/home/gahjelle/structure/structure/structure.py", line 8, in <module>
import files
ModuleNotFoundError: No module named 'files'

因为 import files 基于当前目录去寻找 files,而当执行目录变化以后,肯定就找不到了。

一种解决办法是,在引用文件中,找到引用文件的父目录,并将其加入到 sys.path 中(即 implicit relative imports,隐式相对导入):

1
2
3
# Local imports
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import files

你可能会像在 structure.py 中,使用相对路径导入 files,如:from . import files

但这样也是不行的,因为相对导入在脚本中的解析方式与导入模块中的解析方式不同

一个更好的方式是使用 pip, 你自己创建一个包,使用起来就好像其他的包一样。

何不创建自己的包?

当你通过 pip 安装一个包的时候,它可以在任何地方使用,事实上你也可以做到,首先在你的包文件夹旁边新建两个文件:

  • setup.cfg
  • setup.py

内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# setup.cfg

[metadata]
name = local_structure
version = 0.1.0

[options]
packages = structure

# setup.py

import setuptools

setuptools.setup()

name 和 version,随意。名字的话建议打上标识,比如 local 或者你的用户名,这样可以方便的找出你自己的包。

准备好了之后,就可以创建你自己的包了:

1
python -m pip install -e .

-e 的意思是 editable, 这个非常重要,因为这可以让你更改你的源代码,而不用重新安装你的包。

这条命令会将你的包安装到系统,你可以在任何地方使用你的包,只需执行 from structure import files。不用担心相对路径,绝对路径等问题。

简单的包可以这样直接建立,但如果比较复杂的包则最好附上更多详细的信息,可参考 [How to Publish an Open-Source Python Package to PyPI.

介绍命名空间包

命名空间包可以允许不存在 __init__.py 文件,特别的,它还允许文件分布在不同的文件夹。当你的文件夹中有 py 文件,但是没有 __init__.py 文件,命名空间包会被自动创建。

为了更理解命名空间包,我们直接实现它。

考虑一个需求,要将歌曲的信息序列化:

1
2
3
>>> song = Song(song_id="1", title="The Same River", artist="Riverside")
>>> song.serialize()
'{"id": "1", "title": "The Same River", "artist": "Riverside"}'

现在已经有第三方社区为你实现了一部分工作。

对于 song.serialize(),它接收一个序列化对象,这个序列化对象有基于 json 实现的,有基于 xml 实现的,可能内部实现代码不一样,但暴露的方法名一样,你可以在 song.serialize() 中自动处理。

这两个序列化对象,分别放在不同文件实现,文件目录如下:

1
2
3
4
5
6
third_party/

└── serializers/
├── json.py
└── xml.py

目前看起来不错,这时候你可能想再加一个自己的 yaml 的序列化方法,同样的创建目录:

1
2
3
4
local/

└── serializers/
└── yaml.py

这里虽然代码在不同的目录,但是对于 serializers 来说,在这2个目录里,都有着共同的命名空间。

所以你可以直接这样导入全部的序列化对象:

1
2
3
4
import sys
sys.path.extend(["third_party", "local"])

from serializers import json, xml, yaml

再举一例:

假设你有Python代码的两个不同的目录如下:

1
2
3
4
5
6
7
foo-package/
spam/
blah.py

bar-package/
spam/
grok.py

在这2个目录里,都有着共同的命名空间spam。在任何一个目录里都没有 __init__.py 文件。

让我们看看,如果将 foo-packagebar-package 都加到python 模块路径并尝试导入会发生什么

1
2
3
4
5
>>> import sys
>>> sys.path.extend(['foo-package', 'bar-package'])
>>> import spam.blah
>>> import spam.grok
>>>

两个不同的包目录被合并到一起,你可以导入 spam.blahspam.grok,并且它们能够工作。

在这里工作的机制被称为“包命名空间”的一个特征。

从本质上讲,包命名空间是一种特殊的封装设计,为合并不同的目录的代码到一个共同的命名空间。对于大的框架,这可能是有用的,因为它允许一个框架的部分被单独地安装下载。它也使人们能够轻松地为这样的框架编写第三方附加组件和其他扩展。

导入包的风格规范

导入包不可以太随意,建议将标准包、第三方包、用户自定义包区分:

1
2
3
4
5
6
7
8
9
10
# Standard library imports
import sys
from typing import Dict, List

# Third party imports
import feedparser
import html2text

# Reader imports
from reader import URL

如何导入数据资源?

有时候你的包需要依赖一些数据,如果你想要将这些数据也一起打包分发给你的用户,可能会有一些问题:

  • 数据文件的路径不确定,这取决于用户的配置,包如何分发的,以及安装在哪里
  • 你的数据文件可能在压缩文件或者 .egg 文件中无法直接使用

历史上有过一些对数据资源的解决方案,包括 setuptools.pkg_resources,不过现在在 python3.7 中,有了官方的标准库来解决这个问题,那就是 importlib.resources,对于之前的版本,则需要使用 importlib_resources

命名空间包不支持 importlib.resources

假设你有一个数据文件是关于书籍的,你的目录结构如下:

1
2
3
4
5
books/

├── __init__.py # 空文件,构造包所用
├── alice_in_wonderland.png
└── alice_in_wonderland.txt

如果需要这两个文件,只需要按如下代码操作即可:

1
2
3
4
5
6
7
8
from importlib import resources
# 文本文件, books 为目录名
with resources.open_text("books", "alice_in_wonderland.txt") as fid:
alice = fid.readlines()

# 二进制文件,books 为目录名
with resources.open_binary("books", "alice_in_wonderland.png") as fid:
cover = fid.read()

如果是较老的版本,可以在 import 的时候换成支持的包:

1
2
3
4
try:
from importlib import resources
except ImportError:
import importlib_resources as resources

再来一个例子,你现在需要将你的程序添加一个 logo,你的包目录如下:

1
2
3
4
5
6
7
8
hello_gui/

├── gui_resources/
│ ├── __init__.py
│ ├── hand.png
│ └── logo.png

└── __main__.py

下面的代码显示了你应该如何引用你的 logo 文件:

1
2
3
from importlib import resources
with resources.path("hello_gui.gui_resources", "logo.png") as path:
pass # or do you work

使用动态导入

Python 是一门动态语言(尽管这有时候也算是缺点),这意味着你可以在 python 程序运行的时候,增加类的属性,修改函数的定义、模块的 docstring,甚至你可以修改 print() 函数让它什么都不输出。

1
2
3
4
5
6
7
8
>>> print("Hello dynamic world!")
Hello dynamic world!

>>> # Redefine the built-in print()
>>> print = lambda *args, **kwargs: None

>>> print("Hush, everybody!")
>>> # Nothing is printed

所以你也可以动态的导入一个包:

1
2
3
4
5
6
7
# docreader.py

import importlib

module_name = input("Name of module? ")
module = importlib.import_module(module_name)
print(module.__doc__)

深入Python 的导入系统

当你执行导入操作时候,背后主要发生了三件事:

  1. 搜索
  2. 加载
  3. 绑定到命名空间

import 命令执行的时候,这三步会自动完成,而importlib 只会完成前两步。

有一点需要注意的是,即便你只导入了某个包中的一个属性,整个模块也会被导入,只是其余的部分没有绑定到当前命名空间。

1
2
3
4
5
6
7
>>> from math import pi
>>> pi
3.141592653589793

>>> import sys
>>> sys.modules["math"].cos(pi)
-1.0

sys.modules 相当于系统对导入模块的缓存。当 python 在执行导入的时候,会先去缓存中查找,如果存在了,则不会执行导入。

只导入一次

你的包中有一些方法,它依赖一些数据,这些数据需要从磁盘或者网络读取,你的类在初始化的时候,可能会刷新这些数据,但如果每次初始化就刷新数据,会导致大量时间花在磁盘或网络IO上,可以设计一个单例模式来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class _Population:
def __init__(self):
"""Prepare to read the population file"""
self._data = {}
self.variant = "Medium"

@property # 创建只读属性的装饰器,名字不变,调用无需加括号
def data(self):
"""Read data from disk"""
if self._data: # 已存在,直接返回
return self._data

# 读取文件,保存到 self._data
with resources.open_text(
"data", "WPP2019_TotalPopulationBySex.csv"
) as fid:
# Read data, filter the correct variant
pass
return self._data

关于 property 参考 Python内置属性函数@property详解

刷新要导入的包

当模块属性或者方法有更新,可以使用 importlib 重载

1
importlib.reload(module_name)

理解导入顺序

如果你的模块名字和标准库中的一样,系统会优先使用标准库的。

import 执行时有几步:

  1. 检查模块缓存,sys.modules
  2. 通过查找器查找模块
  3. 通过加载器加载模块

你可以继承 python 的查找器实现你自己的 finder,甚至是自己的 loader,当然可能目前没有必要。

这里想说明的是,导入操作是有顺序的,在执行查找操作时, sys.meta_path 会控制哪个查找器会被调用。

1
2
3
4
5
>>> import sys
>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib_external.PathFinder'>]

这里可以看到,内置的模块先于自定义的被加载。

如果你把当前环境下所有查找器移除,python 就无法查找任何包了,但 python 仍然可以导入一些包,因为有些包已经位于缓冲中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> import sys
>>> sys.meta_path.clear()
>>> sys.meta_path
[]

>>> import math
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'math'

>>> import importlib # Autoimported at start-up, still in the module cache
>>> importlib
<module 'importlib' from '.../python/lib/python3.8/importlib/__init__.py'>

咱们自定义一下系统的查找器,让它在查找的时候打印,这样我们就知道了当我导入一个包,有哪些包导入了。

对于查找器,有一个要求就是它必须要实现 .find_spec() 这个类方法,这个方法会尝试去查找模块,如果它不知道怎么查,它应该返回 None,如果知道,则返回 nodule spec, 如果模块无法找到,则发起 ModuleNotFoundError 错误。

1
2
3
4
5
6
7
8
9
10
11
# debug_importer.py

import sys

class DebugFinder:
@classmethod # 类方法,无需示例即可使用
def find_spec(cls, name, path, target=None):
print(f"Importing {name!r}")
return None

sys.meta_path.insert(0, DebugFinder)

上面的查找器打印后,返回 None, 表示它不知道怎么查,随后会交给其他查找器查。

你可以按需要自定义 sys.meta_path 的加载顺序。

将这个自定义查找器,放在 sys.meta_path 第一位,每次执行 import 你就可以看到所有被导入的模块。

通过这种自定义查找的方法,我们甚至可以写一个自动安装包的查找器,把它插到 sys.meta_path 末尾,因为如果在末尾的位置被执行,这意味着前面的查找器都没有找到你想要的包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# pip_importer.py

from importlib import util
import subprocess
import sys

class PipFinder:
@classmethod
def find_spec(cls, name, path, target=None):
print(f"Module {name!r} not installed. Attempting to pip install")
cmd = f"{sys.executable} -m pip install {name}"
try:
subprocess.run(cmd.split(), check=True)
except subprocess.CalledProcessError:
return None

return util.find_spec(name)

sys.meta_path.append(PipFinder)

只是随便一说,不要放到自己项目中用!可能会带来灾难性后果

例子: 数据文件导入

这个例子灵感的来源是 Aleksey Bilogur, 他有一篇文章(Import Almost Anything in Python: An Intro to Module Loaders and Finders),介绍了模块的加载器和查找器。

你可能实现过自定义的加载器加载数据文件,但能不能利用加载器和查找器直接 import csv 文件呢?就好像下面这个代码一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> import csv_importer
>>> import employees

>>> employees.name # 直接访问列名
('John Smith', 'Erica Meyers')

>>> for row in employees.data: # 直接访问数据
... print(row["department"])
...
Accounting
IT

>>> employees.__file__ # 访问文件名
'employees.csv'

其实是可以的,我们可以将路径传给查找器处理路径的问题,然后通过加载器读取数据文件,最终实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import csv
import pathlib
import re
import sys
from importlib.machinery import ModuleSpec

class CsvImporter():
def __init__(self, csv_path):
pass

@classmethod # 类方法
def find_spec(cls, name, path, target=None):
# 处理路径
# 其他操作
return ModuleSpec(name, cls(csv_path)) # 此处 cls() 构造一个类

def exec_module(self, module):
# 加载文件
# 处理数据
# 绑定到模块中
module.__dict__.update(fields)
module.__dict__["data"] = data
module.__dict__["fieldnames"] = fieldnames
module.__file__ = str(self.csv_path)

其他导入技巧

导入特定版本

1
2
3
4
5
import sys
if sys.version_info >= (3, 7):
from importlib import resources
else:
import importlib_resources as resources

有条件导入你喜欢的包

1
2
3
4
5
6
7
8
9
try:
import ujson as json
except ImportError:
import json

try:
from quicktions import Fraction
except ImportError:
from fractions import Fraction

处理包的缺失

你可能有一些比较酷的想法,比如利用 Colorama 这个包来给你的输出增加一些颜色,但是这个包并不是一个必要的,如果用户电脑上有这个包,那可以,要是没有你希望也可以正常使用你的程序。

你可以参考 testing 中对于 mocks 的使用实现这个想法。

将脚本导入为模块

脚本和模块的区别在于,脚本主要是去 do_something, 而模块则提供函数以供使用。他们都存在于 python 文件中,就 Python 而言,其实它们并没有什么区别。

有时候你的模块可能比较复杂,有脚本也有模块,这时候可以考虑refactor你的模块。

但你也可以让你的模块提供两者的功能,既有函数,也可以直接执行,相信你看到过这种 python 代码:

1
2
3
4
5
def main():
...

if __name__ == "__main__":
main()

从 ZIP 文件启动脚本

新建一个 __main__.py 文件,打包成压缩包,你便可以直接通过 python zip_file_name.zip 这种形式运行。

你可以将你自己的包也按照这种方式处理,但 python 有提供了一个工具 zipapp,它可以帮你处理这些事情。

你只需要在你的包目录执行 :

1
python -m zipapp population_quiz -m population_quiz:main

它会做两件事,一是为你的程序添加入口,二是打包你的程序。

这里的 __main__.py 会自动生成,内容如下:

1
2
3
# -*- coding: utf-8 -*-
import population_quiz
population_quiz.main()

上面的命令执行后,会产生 .pyz 的打包文件,在 windows 上应该可以直接执行,因为 .pyz 文件应该自动关联了运行程序,而在 Linux 或者 Mac 上,可以通过 -p 指定运行环境:

1
2
$ python -m zipapp population_quiz -m population_quiz:main \
> -p "/usr/bin/env python"

注意在 zip 文件中,如果处理数据文件,open 方法会无法使用

处理循环导入

循环导入就是你中有我我中有你,比如

这种情况本会发生无限递归循环,但是因为我们的老朋友模块缓存所以避免了惨剧的发生。

但是在下面这种情况,则会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# yin.py

print(f"Hello from yin")
import yang
number = 42

def combine():
return number + yang.number

print(f"Goodbye from yin")

# yang.py

print(f"Hello from yang")
import yin
number = 24

def combine():
return number + yin.number

print(f"yin and yang combined is {combine()}")
print(f"Goodbye from yang")

执行 import yin

1
2
3
4
5
6
7
8
>>> import yin #导入 yin 的时候,yin 中的 number 没有还没有定义
Hello from yin
Hello from yang
Traceback (most recent call last):
...
File ".../yang.py", line 8, in combine
return number + yin.number
AttributeError: module 'yin' has no attribute 'number'

执行 import yang

1
2
3
4
5
6
>>> import yang  #yang 调用 combine() 的时候,yin 已经初始化完成
Hello from yang
Hello from yin
Goodbye from yin
yin and yang combined is 66
Goodbye from yang

如何避免这种情况呢?其实你的模块如果有存在互相引用,这意味着你的模块设计的不好,你需要想想怎么去组织你的代码。

优化你的导入速度

你可能有些包导入的速度很慢,你想了解具体是在哪里速度变慢,自从 Python3.7 你可以有一个非常简单的办法了解你导入包的速度:

1
2
3
4
5
6
7
$ python -X importtime -c "import datetime"
import time: self [us] | cumulative | imported package
...
import time: 87 | 87 | time
import time: 180 | 180 | math
import time: 234 | 234 | _datetime
import time: 820 | 1320 | datetime

cumulative 列按包显示了导入的累计时间(以微秒为单位)。

总结

这篇文章主要介绍了:

  • 命名空间包
  • 导入资源和数据文件
  • 使用动态导入
  • 扩展 Python 的导入机制
  • 处理不同版本的包

这里还有一些优秀的参考信息:

参考