admin管理员组

文章数量:1646246

1. 官方文档

importlib --- import 的实现 — Python 3.12.3 文档

importlib.resources -- Package resource reading, opening and access — Python 3.12.3 文档

Using importlib_resources - importlib_resources 6.4.1.dev17+g2c30c8a.d20240424 documentation

2. Python 组织代码的方式 (Organize Python Code):Modules\Packages\Namespace packages

包和模块 (modules and packages)都是 Python 语言组织代码的方式,import 是用于导入模块或包的关键字,正确使用 import 能够重复使用代码,保证项目代码的可维护性,提升工作效率。

2.1 Modules

An object that serves as an organizational unit of Python code. Modules have a namespace containing arbitrary Python objects. Modules are loaded into Python by the process of importing.

在实践中:一个模块通常对应于一个包含 Python 代码的 .py 文件。

2.1.1 import math

import math
math.pi
# 3.141592653589793
dir(math)
# ['__doc__', '__loader__', '__name__', '__package__', '__spec__', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'comb', 'copysign', 'cos', 'cosh', 'degrees', 'dist', 'e', 'erf', 'erfc', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'inf', 'isclose', 'isfinite', 'isinf', 'isnan', 'isqrt', 'lcm', 'ldexp', 'lgamma', 'log', 'log10', 'log1p', 'log2', 'modf', 'nan', 'nextafter', 'perm', 'pi', 'pow', 'prod', 'radians', 'remainder', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'tau', 'trunc', 'ulp']

import math 语句,表示导入 math 模块中的代码并使其可用。除了作为一个模块,math 还充当一个命名空间,将模块中所有属性保存在一起,如 math.pi 表示获取 math 模块中的 pi 变量。

可以使用 dir() 列出命名空间的内容,使用不带任何参数的 dir() 显示全局命名空间中的内容,查看math 命名空间的内容,可以使用 dir(math)。命名空间本质上是模块的__dict__属性,是一个字典。

import math

type(math.__dict__)
# dict
set(math.__dict__.keys()) == set(dir(math))
# True

Namespace 实际上是一个字典对象,存储了变量名和对象的对应关系。

Python 中有四种命名空间:内置命名空间、全局命名空间、局部命名空间和非局部命名空间(也称为嵌套命名空间)。 

  1. 内置命名空间:Python 解释器启动时自动加载的命名空间,包含了 Python 内置的函数和对象,如 print()、len()、int() 等。
  2. 全局命名空间:在模块中定义的变量和函数名都属于全局命名空间,可以在整个模块中访问。
  3. 局部命名空间:函数和类中定义的变量和函数名都属于局部命名空间,只能在函数或类中访问。
  4. 非局部命名空间:用于嵌套函数中,指的是外层函数的命名空间,可以在内层函数中访问。

2.1.2 from math import pi (导入模块的特定部分)

from math import pi
pi
# 3.141592653589793
math.pi
# name 'math' is not defined

pi 将被放在全局命名空间中,而不是放在 math 命名空间中。

2.1.3 import math as m (使用 as 关键字为导入的模块指定别名)

import math as m
m.pi
# 3.141592653589793

from math import pi as PI
PI
# 3.141592653589793

2.2 Regular packages

A Python module which can contain submodules or recursively, subpackages. Note that a package is still a module. As a user, you usually don’t need to worry about whether you’re importing a module or a package.

在实践中,一个包 package 通常对应于一个包含一个或多个 .py 文件以及其他子文件夹的文件夹。

一个包中必须包含一个 ‘__init__.py’ 文件,这个文件可以为空,也可以包含代码,创建 Python package 时,需要创建一个文件夹并在里面放一个名为 ‘__init__.py’ 的文件。

没有 ‘__init__.py’ 文件的目录仍然被 Python 视为包,不过不是普通的包,而是称为命名空间包 (namespace packages)

‘__init__.py’ 的文件的作用有以下几个:

  1. 声明包的命名空间:‘__init__.py’ 文件告诉 Python 解释器该目录是一个包,这样就可以使用 import 语句导入包中的模块。
  2. 执行包的初始化:‘__init__.py’ 文件也可以包含 Python 代码,这些代码将在导入包时被执行,这些代码可以用于初始化包的状态,例如设置全局变量、初始化数据库连接等。
  3. 控制包的导入行为:‘__init__.py’ 文件还可以定义 ‘__all__’ 变量,该变量指定了导入包时应该导入哪些模块。

示例:world package (关于 __init__ )

创建 world package

先运行下述代码,生成一个 Python Package (运行完后建议查看一下文件夹中各个文件的内容),生成的 world package 结构如下:

├─world
├─ __init__.py

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

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

<helper_make_world_package.py>

import os

os.makedirs('world', exist_ok=True)
os.makedirs(r'world/africa', exist_ok=True)
os.makedirs(r'world/europe', exist_ok=True)

path_list = [
    r'world/africa/zimbabwe.py',
    r'world/europe/greece.py',
    r'world/europe/norway.py',
    r'world/europe/spain.py',
    r'world/africa/__init__.py',
    r'world/europe/__init__.py',
    r'world/__init__.py'
]
text_list = [
    'print("Shona: Mhoroyi vhanu vese")\n'
    'print("Ndebele: Sabona mhlaba")',
    'print("Greek: Γειά σας Κόσμε")',
    'print("Norwegian: Hei verden")',
    'print("Castellano: Hola mundo")',
    '',
    'from . import greece\n'
    'from . import norway',
    'from . import africa'
]
# 创建py文件
for path, text in zip(path_list, text_list):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)

说明:每个国家模块在导入时,会打印问候语。world/__init__.py 只导入 africa 而未导入 europe;world/africa/__init__.py 没有导入任何东西;world/europe/__init__.py 只导入 greece 和 norway,未导入 spain。

subpackages

由于 world/__init__.py 只导入了 africa,未导入 europe。因此,使用 import world 语句导入 world package,自动导入 africa subpackage,但没有导入 europe subpackage。

import world

world
# <module 'world' from 'D:\\study_code\\PYTJupyterNotebook\\world\\__init__.py'>

world.africa
# <module 'world.africa' from 'D:\\study_code\\PYTJupyterNotebook\\world\\africa\\__init__.py'>

world.europe
# module 'world' has no attribute 'europe'

额外导入europe package # Import spain explicitly inside the world namespace:

import world.europe
# Greek: Γειά σας Κόσμε
# Norwegian: Hei verden
world.europe
# <module 'world.europe' from 'D:\\study_code\\PYTJupyterNotebook\\world\\europe\\__init__.py'>

或者 # Import europe explicitly:

from world import europe
# Greek: Γειά σας Κόσμε
# Norwegian: Hei verden
europe
# <module 'world.europe' from 'D:\\study_code\\PYTJupyterNotebook\\world\\europe\\__init__.py'>
europe.greece
# <module 'world.europe.greece' from 'D:\\study_code\\PYTJupyterNotebook\\world\\europe\\greece.py'>
# europe.spain
# module 'world.europe' has no attribute 'spain'

# import world ## 如果已经导入了 world,现在 world 的命名空间中可以找到 europe,如果之前没有执行过 import world,此时 world.europe 将报错
world.europe
# <module 'world.europe' from 'D:\\study_code\\PYTJupyterNotebook\\world\\europe\\__init__.py'>

如果一个子包没有在 ‘__init__.py’ 文件中被导入,但是在导入父包后直接导入子包,Python 会按照 sys.path 的顺序搜索并加载该子包,然后将其添加到父包的命名空间中。

submodules

由于 world/africa/__init__.py file 内容为空,导入 world.africa package 仅创建了命令空间,没有其他动作。

# import world
# world.africa.zimbabwe
# module 'world.africa' has no attribute 'zimbabwe'

from world.africa import zimbabwe
# Shona: Mhoroyi vhanu vese
# Ndebele: Sabona mhlaba

zimbabwe
# <module 'world.africa.zimbabwe' from 'D:\\study_code\\PYTJupyterNotebook\\world\\africa\\zimbabwe.py'>

world.africa.zimbabwe
# <module 'world.africa.zimbabwe' from 'D:\\study_code\\PYTJupyterNotebook\\world\\africa\\zimbabwe.py'>  
# Note that zimbabwe can also be reached through the africa subpackage

总结:导入模块会加载内容,同时也会创建一个包含内容的命名空间。相同的模块有可能是不同命名空间的一部分。

2.3 Namespace packages

Python 语言中,模块和包与文件和文件夹密切相关,这一点是 Python 与许多其他编程语言的区别,在其他编程语言中,包仅仅充当命名空间,而与源代码的组织方式无关。

Namespace package(Python 3.3 引入)较少依赖于底层的文件层次结构(File Hierarchy),它可以跨越多个文件夹。对于有一个包含 .py 文件且不包含 __init__.py 的文件夹,namespace package 将自动创建。

下面通过一个例子,来了解一下 namespace package 的用处(建议先阅读 <2. Regular Packages 进阶> ,进一步学习 package 相关内容,然后再阅读本节)。

示例:serializers namespace package

创建 serializers namespace package

首先,定义一个 Song 对象,含有 song_id、title 和 artist 属性和 serialize() 方法。创建 Song 实例时:song = Song(song_id="1", title="The Same River", artist="Riverside")。.serialize() 方法用于将 Song 对象表示为其他字符串形式,该方法接收一个 serializer(转换器)参数,转换器使用统一的接口:start_object() 和 add_property。

假设恰好有一个名为 serializers 的第三方库,可以帮助序列化 Song 对象,serializers 是一个 namespace package,提供了 serializer 对象 JsonSerializer 和 XmlSerializer,它们可作为 Song.serialize() 的参数。JsonSerializer 和 XmlSerializer都实现了相同的接口,包括 start_object(),add_property 和  __str__() 方法。serializers 包的文件结构如下:

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

执行下述代码,将自动生成 song.py,song_main.py,serializers 包;通过从 sys.path 中找到 site-package 的路径位置,把 serializers 文件夹放入 site-package 文件夹中,达到类似于安装了第三方库 serializers 的效果。

看过 2.1~2.4 节后,可使用 pip 方法安装本地 serializers 包;或者把 serializers 文件夹放在本地的 third_party 文件夹中,然后在 song_main.py 中使用 sys.path.extend 把 “third_party” 添加到 sys.path 中。

<helper_make_song_package>

import os
import sys
import re

index_list = [num_i for num_i, path in enumerate(sys.path) if re.fullmatch(r'(.*?)\\site-packages$', path) is not None]
if index_list:
    folder_path = os.path.join(sys.path[index_list[-1]], 'serializers')
else:
    folder_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'third_party', 'serializers')
    # 如果没有找到site-packages文件夹,song_main.py需添加下面两行代码,把在本地创建third_party文件夹添加到sys.path中
    # import sys
    # sys.path.extend(["third_party"])
os.makedirs(folder_path, exist_ok=True)
print(f'serializers package location: {folder_path}')
path_list = [
    r'song.py',
    r'song_main.py',
    os.path.join(folder_path, 'json.py'),
    os.path.join(folder_path, 'xml.py'),
]
text_list = [
    """# song.py
class Song:
    def __init__(self, song_id, title, artist):
        self.song_id = song_id
        self.title = title
        self.artist = artist
    def serialize(self, serializer):
        serializer.start_object("song", self.song_id)
        serializer.add_property("title", self.title)
        serializer.add_property("artist", self.artist)
        return str(serializer)
    """,
    """# main.py
from serializers.json import JsonSerializer
from serializers.xml import XmlSerializer
from song import Song
 
 
song = Song(song_id="1", title="The Same River", artist="Riverside")
song1 = song.serialize(JsonSerializer())
print(song1)
# {"id": "1", "title": "The Same River", "artist": "Riverside"}
song2 = song.serialize(XmlSerializer())
print(song2)
# <song id="1"><title>The Same River</title><artist>Riverside</artist></song>
    """,
    """# third_party/serializers/json.py
import json
 
 
class JsonSerializer:
    def __init__(self):
        self._current_object = None
    def start_object(self, object_name, object_id):
        self._current_object = dict(id=object_id)
    def add_property(self, name, value):
        self._current_object[name] = value
    def __str__(self):
        return json.dumps(self._current_object)
    """,
    """# third_party/serializers/xml.py
import xml.etree.ElementTree as et
 
 
class XmlSerializer:
    def __init__(self):
        self._element = None
    def start_object(self, object_name, object_id):
        self._element = et.Element(object_name, attrib={"id": object_id})
    def add_property(self, name, value):
        prop = et.SubElement(self._element, name)
        prop.text = value
    def __str__(self):
        return et.tostring(self._element, encoding="unicode")
    """
]
# 创建py文件
for path, text in zip(path_list, text_list):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)
使用本地文件夹补充内容

现在有个问题,需要将 Song 对象转换为 yaml 形式,而这种格式第三方库 serializers 目前不支持。namespace package 的神奇之处在于,可以把自己编写的 YamlSerializer 添加到包中,而不必直接改动第三方库。

首先,在本地创建一个名为 serializers 的文件夹(需要确保它像普通包一样可用,可以通过从适当的目录运行 Python 脚本或使用 pip 安装本地库达到这一目的),注意:文件夹的名称需要与正在定制的命名空间包的名称一致。

本地 serializers 文件夹 <yaml.py>

# serializers/yaml.py

import yaml
from serializers.json import JsonSerializer


class YamlSerializer(JsonSerializer):
    def __str__(self):
        return yaml.dump(self._current_object)
 

使用这个由本地包 ‘假冒的’ 第三方包:

3. Regular packages 进阶: 包内部使用 import

3.1 绝对引用和相对引用 (Absolute and Relative Imports)

在1.2 节,world paceage 的 ‘__init__’ 文件中,代码 ‘from . import africa’ 中的点 (.) 指的是什么呢 -- 当前包。这是典型的相对引用,可以把它读成 “从当前包中导入子包 africa”。这个语句等效的绝对引用语句为(显式地写出当前包):from world import africa。

相对引用是一种在包内部使用的 import 方式,用于包内部引用其他包或模块。导入的位置必须以点开头,通常使用 “.” 和 “..” 来表示当前模块所在的目录和上一级目录,、能向上相对多少级,完全取决于模块名称中有多少层。

Python 有两种加载文件的方法:一种是直接执行文件(__name__ 等于 __main__),另一种是把文件当做模块(__name__ 不等于 __main__)。如果直接运行 .py 文件,比如在终端中输入 ‘python file.py’ 命令,这个 Python 文件被作为主程序运行,它就成为了主模块(包含主程序的模块,主模块的名称是__main__)。如果输入 ‘python -m file.py’ (Python 解释器命令行选项 -m 用于执行 Python 模块)或者在其他文件当中使用 import 来导入这个文件,这个文件就被当做模块。在同一时间里,只有一个主模块,主模块常被称为顶层脚本,顶层脚本可以理解为:让程序从这里开始的 Python 文件。

注意:主模块中不能使用相对导入,相对导入只能在模块文件当中使用。当模块名称为 main 时,Python 解释器会将该模块作为主程序运行,必须使用绝对引用,不能使用相对引用。相对引用是相对于当前模块的路径进行引用的,而当模块作为主程序运行时,它没有父模块,因此无法进行相对引用(使用相对引用报错:attempted relative import with no known parent package)。主模块所在文件夹不会被视作 package,因此除了主模块外,与主模块处在同个文件夹的模块(同级模块)也必须使用绝对引用。

3.2 Import Path

Python 去哪里需按照需要导入的包和模块呢 -- 后续会详细介绍 import 的机制,暂时可以理解为,Python 从 Import Path (导入路径列表  a list of locations) 中查找需要导入的包和模块,从列表的开头开始,在每个路径下查找目标包和模块,直到第一个匹配。

注:当你输入 import 时,Python 在搜索 Import Path 之前,先在几个不同的地方寻找,比如在 sys.modules 中查看已经导入的内容,以及查找 Python 的内置模块 built-in modules,更多细节将在后续内容介绍。

可以通过打印 sys.path 来查看 Python 的 Import Path,此列表中通常包括三种路径:

  1. 当前脚本所在文件夹
  2. PYTHONPATH 环境变量的内容,可通过 print(os.environ.get('PYTHONPATH')) 查看
  3. 其他与安装相关的目录,如 site-packages 文件夹

由于脚本所在文件夹总是在此列表的第一个,可以通过组织目录并注意从哪个文件夹下运行来确保脚本能够找到自定义的模块和包。需要注意的是,自定义的模块和包的名字,不应与 Python 内置模块或者标准库中模块重名,否则可能会得到意外的结果。

print(sys.path)

# ['D:\\study_code\\PYTJupyterNotebook',
#  'C:\\anaconda3\\envs\\projecta\\python39.zip',
#  'C:\\anaconda3\\envs\\projecta\\DLLs',
#  'C:\\anaconda3\\envs\\projecta\\lib',
#  'C:\\anaconda3\\envs\\projecta',
#  '',
#  'C:\\Users\\q00573389\\AppData\\Roaming\\Python\\Python39\\site-packages',
#  'C:\\anaconda3\\envs\\projecta\\lib\\site-packages',
#  'C:\\anaconda3\\envs\\projecta\\lib\\site-packages\\win32',
#  'C:\\anaconda3\\envs\\projecta\\lib\\site-packages\\win32\\lib',
#  'C:\\anaconda3\\envs\\projecta\\lib\\site-packages\\Pythonwin']

3.3 示例:structure package (关于如何在包内部使用 import)

创建 structure package

运行下述代码,生成如下结构的 Structure 文件夹。该文件夹的主要功能是:重建给定的文件夹结构(包括创建目录和创建同名空文件)。其中,structure.py 文件为主程序,而 files.py 是包含一些用于处理文件的函数。

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

<helper_make_structure_package.py>

import os

os.makedirs('structure', exist_ok=True)
path_list = [
    r'structure/files.py',
    r'structure/structure.py',
]
text_list = [
    """
def unique_path(directory, name_pattern):
    \"\"\" Find a path name that does not already exist \"\"\"
    counter = 0
    while True:
        counter += 1
        path = directory / name_pattern.format(counter)
        if not path.exists():
            return path
 
 
def add_empty_file(path):
    \"\"\" Create an empty file at the given path \"\"\"
    print(f"Create file: {path}")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.touch()
    """,
    """
# Standard library imports
import pathlib
import sys
# Local imports
import files
 
 
def main():
    # Read path from command line
    try:
        root = pathlib.Path(sys.argv[1]).resolve()
    except IndexError:
        print("Need one argument: the root of the original file tree")
        raise SystemExit()
    # Re-create the file structure
    new_root = files.unique_path(pathlib.Path.cwd(), "{:03d}")
    for path in root.rglob("*"):
        if path.is_file() and new_root not in path.parents:
            rel_path = path.relative_to(root)
            files.add_empty_file(new_root / rel_path)
 
 
if __name__ == "__main__":
    main()
 
    """
]
# 创建py文件
for path, text in zip(path_list, text_list):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)

使用时,在 structure 目录下运行 structure.py 文件,并传递一个路径参数(可以使用 . 表示复制当前文件夹)。下面是该应用程序的输出示例:

 需要关注的是,structure.py 文件中,对 files.py 文件内容的引用,即对 files 的 import。

如果 structure 文件夹外部还有一个 test.py 文件:

├─study_code                             
│  │  test.py                      
│                               
├─structure
│  │  files.py
│  │  structure.py
└─

<test.py>

from structure.structure import main


if __name__ == "__main__":
    main()

当使用 test.py 启动应用程序时,由于当前脚本的位置改变,import path 也随之发生改变,Files 不再位于 import path 之中,因此导入失败。

解决方法一:改变 Import Path

可以通过 print(sys.path) 命令查看当前的 import path,只要保证能从 import path 中找到 files.py 文件即可。

<files.py>

sys.path.insert(0, str(pathlib.Path(__file__).parent))
import files

‘sys.path’ 是一个列表,可以通过直接修改它来添加或删除搜索路径。使用 ‘sys.path.insert’ 可以更方便地控制要插入的路径的位置:‘index’ 为 0,表示将 ‘path’ 插入到搜索路径列表的最前面; ‘index’ 为 -1,表示将 ‘path’ 插入到搜索路径列表的最后面。

运行结果: 

经过上述修改,无论从 test.py 还是从 structure.py,代码都能正常运行:如果从 test.py 运行,打印 sys.path,前两项为:'D:\\study_code\\structure', 'D:\\study_code';如果从 structure.py 运行,打印 sys.path,前两项为:'D:\\study_code\\structure', 'D:\\study_code\\structure';只要 import path 中能找到路径 'D:\\study_code\\structure',import files 这句就不会产生报错。

这种解决方案的缺陷是:代码中 import 部分可能会变得比较混乱,难以理解。

解决方法二:使用相对引用

不过此时,将无法像之前那样,从 structure 文件夹直接运行,报错原因可以参考 2.1 内容:

对于这个报错,可以使用 try...except 规避。

try:
    import files
except ImportError:
    from . import files

更好且更稳定的解决方案是使用 Python 的 import and packaging system,并使用 pip 将项目作为本地包安装。从 PyPI · The Python Package Index 安装的包,可用于环境中的所有脚本;从计算机本地安装包,也能达到同样的效果。具体操作在 2.4 节介绍。

3.4 使用 pip 创建 local package

Step1:在 structure 文件夹外部创建 setup.cfg 和 setup.py

‘setup.cfg’ 是 Python 包的配置文件,用于指定包的元数据(包的名称、版本、作者、描述等)和打包选项(包依赖的其他 Python 包,需要排除的一些不需要打包的目录等)。‘setup.cfg’ 文件必须与 ‘setup.py’ 文件位于同一目录下。

这里提供一段代码用于便捷的生成这两个文件。‘setup.cfg’ 中 name 和 version 可以根据实际情况修改,后续 pip 命令会用到它们,因此尽量定义为便于识别且不会与其他包冲突的值,可以对所有这类的本地包都采用固定的前缀,如 local_ 或用户名。‘setup.cfg’ 中 packages 需要列出包含源代码的目录。

<helper_make_local_package.py>

import os
folder_name = 'study_code'
path_list = [
    os.path.join(os.path.dirname(__file__), 'setup.cfg'),
    os.path.join(os.path.dirname(__file__), 'setup.py'),
]
text_list = [
    """
[metadata]
name = local_structure
version = 0.1.0
description = My Python Package
 
[options]
packages = structure
    """,
    """
 
import setuptools
 
setuptools.setup()
    """
]
# 创建py文件
for path, text in zip(path_list, text_list):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)

Step2:使用 pip 安装本地包 & 调整包内部 import 语句

安装

使用命令 ‘python -m pip install -e .’ 把包安装到本地 Python 环境中。在此命令中 “.” 表示当前目录,说明当前目录中含有一个 ‘setup.py 文件’,这条命令将把当前目录下的 Python 包安装到环境中。运行此命令时,pip 会查找当前目录下的 ‘setup.py’ 文件,并根据其中的指示安装包。命令中的 e 选项代表可编辑,这一点很重要,它表示允许更改软件包的源代码,而无需重新安装。

安装好后,structure 能够在 Python 的 import path 中被找到,这意味着你可以在任何地方使用 structure,无需担心脚本目录、相对导入等等因素。

调整 structure.py 中 import 语句

# Local imports
from structure import files

在自己编写代码时,应该有意识地将脚本和库分开。这里有一个很好的经验法则:

  • 脚本是用来运行的。
  • 库是要导入的。

如果你有一些代码,既想单独运行,又想在其他脚本中导入,这种情况下,通常需要重构代码,将公共部分拆分为库模块。

4. Import 代码格式

为了保持代码的可读性和可维护性,使用 import 时,代码格式可采用如下经验法则:

  • 将 import 内容保存在文件的顶部;
  • 在单独的行上编写 import;
  • 将导入分组:首先是标准库导入,然后是第三方导入,最后是本地应用程序或库导入;在每个组中内容按字母顺序排列;
  • 比起相对导入,更推荐绝对导入;
  • 避免使用像 from module import * 这样带有通配符导入。

5. 导入资源 Resource

程序有时需要依赖某些数据文件或其他资源。在一些简单场景下,这可能不是问题:指定数据文件的路径即可;但如果资源文件对你的程序很重要,并且你需要将程序共享给其他用户,则会出现一些挑战:资源路径取决于用户的设置以及包的安装方式,虽然可以尝试根据 ‘__file__’ 或 ‘__path__’ 找出资源路径,但在不同的子包或者子模块中,这个路径也会有差异,代码会比较复杂,且定义路径时需要格外仔细。此外,你的包可能存在于 zip 文件或旧的 .egg 文件中,在这种情况下,资源已不再是用户系统上单独的物理文件,因此也不再适合通过路径的方式找到资源。

Python 3.7 新增了 importlib.resources 模块,用于访问包内资源(如文件、图片、模板等)。importlib.resources 提供了一种处理资源的标准方式,在不依赖于文件系统的情况下,访问包内的资源。

5.1 importlib.resources

importlib.resources 允许访问包内的资源,资源是位于可导入包中的任何文件。使用 importlib.resources 时有一个要求:资源文件必须在常规包中可用,不支持命名空间包,在实践中,这表示该文件必须位于包含 ‘__init__.py’ 的文件夹中。通过使用 importlib.resources ,能够以一致的方式处理包中的文件,同时也更容易访问其他包中的资源文件。

If you can import a package, you can access resources within that package. 

假设含有资源文件的包 books 具有如下结构,其中 ‘__init__.py’ 是空文件,用以表明 books 为常规包。包内部的文件内容可以自行设置。

├─books

├─ __init__.py
├─ logo.png
└─ 李白介绍.txt

可以使用如下代码读入 txt 和 png:

<Python 3.9>

<study_main.py>

from importlib import resources
with resources.open_text(package="books", resource="李白介绍.txt", encoding='utf-8') as fid:
    txt_context = fid.readlines()
print(''.join(txt_context))

with resources.open_binary(package="books", resource="logo.png") as fid:
    png_binary = fid.read()
print(png_binary)

importlib.resources.open_text() 和 importlib.resources.open_binary() 等效于内置的 open(),mode参数对应 rt 和 rb。

study_main.py 运行结果:

上述代码是基于 Python 3.9 的,importlib.resources.open_text() 和 importlib.resources.open_binary() 以及其他一些函数预计会在未来的 Python 版本中被移除,因为这些函数假定所有资源都直接位于package 之下,不支持文件夹。对于 Python 3.11 及之后的版本,代码内容有修改,具体参考下一小节。

<Python 3.12>

<study_main.py>

from importlib import resources
with resources.files("books").joinpath("李白介绍.txt").open('r', encoding='utf-8') as fid:
    txt_context = fid.readlines()
print(''.join(txt_context))

with resources.files("books").joinpath("logo.png").open('rb') as fid:
    png_binary = fid.read()
print(png_binary)
 

study_main.py 运行结果:

5.2 导入文件

5.2.1 准备数据

下载示例数据文件

World Population Prospects - Population Division - United Nations

下载好的 .csv 文件内容概览:

将数据文件组织成一个 package

├─data

├─ __init__.py
└─WPP2022_TotalPopulationBySex.csv

5.2.2 应用数据

基于上述数据,要求实现一个具有如下功能的程序:从数据文件中读取某一年份所有国家的人口数据;随机取三个国家,提示用户猜测哪个国家在这一年份人口最多,读取用户输入并与真实结果对比,判断用户猜测结果是否正确。

<population_quiz.py>

# population_quiz.py
import csv
import random
from importlib import resources


def read_population_file(year, variant="Medium"):
    """Read population data for the given year and variant"""
    population = {}

    print(f"Reading population data for {year}, {variant} scenario")
    with resources.files("data").joinpath("WPP2022_TotalPopulationBySex.csv").open(encoding='utf-8') as fid:
        rows = csv.DictReader(fid)
        # Read data, filter the correct year
        for row in rows:
            if int(row["LocID"]) < 900 and row["Time"] == year and row["Variant"] == variant:
                pop = round(float(row["PopTotal"]) * 1000)
                population[row["Location"]] = pop
    return population


def run_quiz(population, num_questions, num_countries):
    """Run a quiz about the population of countries"""
    num_correct = 0
    for q_num in range(num_questions):
        print(f"\n\nQuestion {q_num + 1}: ")
        countries = random.sample(list(population.keys()), num_countries)
        print("\n".join(f"{i}. {a}" for i, a in enumerate(countries, start=1)))

        # Get user input
        while True:
            guess_str = input("\nWhich country has the largest population? ")
            try:
                guess_idx = int(guess_str) - 1
                guess = countries[guess_idx]
            except (ValueError, IndexError):
                print(f"Please answer between 1 and {num_countries}")
            else:
                break

        # Check the answer
        correct = max(countries, key=lambda k: population[k])
        if guess == correct:
            num_correct += 1
            print(f"Yes, {guess} is most populous ({population[guess]})")
        else:
            print(
                f"No, {correct} ({population[correct]}) is more populous "
                f"than {guess} ({population[guess]})"
            )

    return num_correct


def main():
    """Read population data and run quiz"""
    population = read_population_file("2020")
    num_correct = run_quiz(population, num_questions=3, num_countries=3)
    print(f"\nYou answered {num_correct} questions correctly")


if __name__ == "__main__":
    main()

上述代码中,函数 read_population_file 通过 importlib.resources 打开数据文件,读取数据时,要求 LocID 小于 900 是因为 LocID 为 900 及以上的位置不是真正的国家,而是像 World、Asia 等这样的集合。read_population_file 最终返回一个包含各个国家人口总数的字典,作为后续游戏功能的输入。

运行结果:

6. 动态导入

动态导入是指在程序运行时,根据需要动态地导入模块或者函数,而不是在程序开头就导入所有可能用到的模块。这种方式可以提高程序的运行效率和灵活性,在 Python 中,可以使用 ‘importlib’ 模块中的 ‘import_module’ 函数来实现动态导入。

6.1 importlib.import_module

importlib.Import_module() 返回一个模块对象,可以将其绑定到任何变量。

# docreader.py

import importlib

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

6.2 serializers namespace package ver2:应用工厂方法(Factory Method) 

6.2.1 工厂方法 Factory Method

Factory Method(工厂方法)是一种创建型设计模式(Creational Design Pattern,通过定义用于创建对象的接口,将对象的创建过程与客户端代码分离。

下面通过一个例子加深对工厂方法理解:假设有一款用于把英语翻译成其他语言的应用程序,目前支持10种语言,经过一段时间的推广,这款程序广为流行,需求增长,需要加入5种以上新的的语言。

6.2.1.1 未使用工厂方法

在客户端代码中,显式地创建每个子类的实例。这种方式虽然简单,但是如果需要添加新的语言时,就需要修改客户端代码,导致代码的可维护性变差。

# Python Code without using Factory method 

class FrenchLocalizer:

	""" it simply returns the french version """

	def __init__(self):

		self.translations = {"car": "voiture", "bike": "bicyclette", "cycle":"cyclette"}

	def localize(self, msg):

		"""change the message using translations"""
		return self.translations.get(msg, msg)

class SpanishLocalizer:
	"""it simply returns the spanish version"""

	def __init__(self):

		self.translations = {"car": "coche", "bike": "bicicleta", "cycle":"ciclo"}

	def localize(self, msg):

		"""change the message using translations"""
		return self.translations.get(msg, msg)

class EnglishLocalizer:
	"""Simply return the same message"""

	def localize(self, msg):
		return msg

if __name__ == "__main__":

	# main method to call others
	f = FrenchLocalizer()
	e = EnglishLocalizer()
	s = SpanishLocalizer()

	# list of strings
	message = ["car", "bike", "cycle"]

	for msg in message:
		print(f.localize(msg))
		print(e.localize(msg))
		print(s.localize(msg))
6.2.1.2 使用工厂方法

使用工厂模式来解决上述问题时,先定义一个 ‘Factory’ 工厂类,该类负责创建不同语言的翻译器。将翻译器具体的创建过程封装在工厂类中,客户端代码无需关心具体的实现细节,只需要调用工厂类的方法,即可创建对应的翻译器。如果需要添加新的翻译器,只需修改工厂类的代码,不需要修改客户端代码,从而可以提高代码的可维护性。

# Python Code for factory method 

class FrenchLocalizer:

	""" it simply returns the french version """

	def __init__(self):

		self.translations = {"car": "voiture", "bike": "bicyclette",
							"cycle":"cyclette"}

	def localize(self, msg):

		"""change the message using translations"""
		return self.translations.get(msg, msg)

class SpanishLocalizer:
	"""it simply returns the spanish version"""

	def __init__(self):
		self.translations = {"car": "coche", "bike": "bicicleta",
							"cycle":"ciclo"}

	def localize(self, msg):

		"""change the message using translations"""
		return self.translations.get(msg, msg)

class EnglishLocalizer:
	"""Simply return the same message"""

	def localize(self, msg):
		return msg

def Factory(language ="English"):

	"""Factory Method"""
	localizers = {
		"French": FrenchLocalizer,
		"English": EnglishLocalizer,
		"Spanish": SpanishLocalizer,
	}

	return localizers[language]()

if __name__ == "__main__":

	f = Factory("French")
	e = Factory("English")
	s = Factory("Spanish")

	message = ["car", "bike", "cycle"]

	for msg in message:
		print(f.localize(msg))
		print(e.localize(msg))
		print(s.localize(msg))

6.2.2 serializers namespace package ver2

前文 1.3 节已经实现了对 serializers namespace package 添加自定义的 ‘转换器’ yaml serializer,这一小节将对 serializers 做进一步优化:应用动态导入 dynamic import 创建 serializer factory(factory.py)。

serializers -> <factory.py>

# local/serializers/factory.py

import importlib


def get_serializer(target_format):
    try:
        module = importlib.import_module(f"serializers.{target_format}")
        serializer = getattr(module, f"{target_format.title()}Serializer")
    except (ImportError, AttributeError):
        raise ValueError(f"Unknown format {target_format!r}") from None

    return serializer()


def serialize(serializable, target_format):
    serializer = get_serializer(target_format)  # dynamic import
    serializable.serialize(serializer)
    return str(serializer)

<song_main2.py>

from serializers import factory
from song import Song
song = Song(song_id="1", title="The Same River", artist="Riverside")

song1 = factory.serialize(song, "json")
print(song1)
song2 = factory.serialize(song, "yaml")
print(song2)

try:
    song3 = factory.serialize(song, "yamll")  # error input
except ValueError:
    print('Error Input')

运行结果:

6.3 示例:greeter plugin package

Python中,package 是多个 module 组成的集合,plugin package 是一种可插拔的 package,它允许程序在运行时动态地加载,而不必像普通的 package,在程序运行前就要进行导入和加载。plugin package 具有更高的灵活性和可扩展性,可以让程序更容易地适应不同的需求和场景。

下面提供一个简化版本的插件示例(完整的实现还需要增加对异常情况,如插件缺失等的处理)。

6.3.1 创建 greeter plugin package

运行下述代码,生成全部文件,包括 greeter plugin package 以及主程序 greeter_main.py,最终文件结构如下:

├─greeter
│  │  __init__.py
│  │  hello.py
│  │  howdy.py
│  └─yo.py                      
├─plugins.py                                              
└─greeter_main.py  

import os

os.makedirs('greeter', exist_ok=True)
path_list = [
    'plugins.py',
    r'greeter/__init__.py',
    r'greeter/hello.py',
    r'greeter/howdy.py',
    r'greeter/yo.py',
    'greeter_main.py',
]
text_list = [
    """# plugins.py
import functools
import importlib
from collections import namedtuple
from importlib import resources

# Basic structure for storing information about one plugin
Plugin = namedtuple("Plugin", ("name", "func"))

# Dictionary with information about all registered plugins
_PLUGINS = {}


def register(func):
    \"\"\"Decorator for registering a new plugin\"\"\"
    package, _, plugin = func.__module__.rpartition(".")
    pkg_info = _PLUGINS.setdefault(package, {})
    pkg_info[plugin] = Plugin(name=plugin, func=func)
    return func
 
 
def names(package):
    \"\"\"List all plugins in one package\"\"\"
    _import_all(package)
    return sorted(_PLUGINS[package])
 
 
def get(package, plugin):
    \"\"\"Get a given plugin\"\"\"
    _import(package, plugin)
    return _PLUGINS[package][plugin].func
 
 
def call(package, plugin, *args, **kwargs):
    \"\"\"Call the given plugin\"\"\"
    plugin_func = get(package, plugin)
    return plugin_func(*args, **kwargs)
 
 
def _import(package, plugin):
    \"\"\"Import the given plugin file from a package\"\"\"
    importlib.import_module(f"{package}.{plugin}")
 
 
def _import_all(package):
    \"\"\"Import all plugins in a package\"\"\"
    files = resources.contents(package)
    plugins = [f[:-3] for f in files if f.endswith(".py") and f[0] != "_"]
    for plugin in plugins:
        _import(package, plugin)
 
 
def names_factory(package):
    \"\"\"Create a names() function for one package\"\"\"
    return functools.partial(names, package)
 
 
def get_factory(package):
    \"\"\"Create a get() function for one package\"\"\"
    return functools.partial(get, package)
 
 
def call_factory(package):
    \"\"\"Create a call() function for one package\"\"\"
    return functools.partial(call, package)  
    """,
    """import plugins
 
greetings = plugins.names_factory(__package__)
greet = plugins.call_factory(__package__)
    """,
    """import plugins
 
 
@plugins.register
def greet(name):
    print(f"Hello {name}, how are you today?")
    """,
    """import plugins
 
 
@plugins.register
def greet(name):
    print(f"Howdy good {name}, honored to meet you!")
    """,
    """import plugins
 
 
@plugins.register
def greet(name):
    print(f"Yo {name}, good times!")
    """,
    """import greeter
import random
 
greeter.greet(plugin="howdy", name="Guido")
print(greeter.greetings())
greeting = random.choice(greeter.greetings())
greeter.greet(greeting, name="Frida")
greeting = random.choice(greeter.greetings())
greeter.greet(greeting, name="Frida")
    """

]
# 创建py文件
for path, text in zip(path_list, text_list):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)

greeter_main.py 运行结果:

6.3.2 plugins.py 代码解释

_PLUGINS:存储已注册的插件
register 装饰器函数

‘@register’ 装饰器:装饰器是 Python 中的一个高级特性,能够在不修改函数内部代码的情况下,给函数增加额外的功能,装饰器需要返回一个可调用对象(通常是一个函数),这样即可在额外执行一些操作的同时,保证原函数的行为不会因为装饰器而发生改变。register 函数用于把模块中的函数注册为插件,添加到 plugins._PLUGINS[package] 字典中(键为插件名,此处是模块文件名;值为 Plugin 实例),可通过此字典调用注册的插件。

根据当前代码对 _PLUGINS 的定义,插件名称是它所属包中模块的名称,即函数所在的 .py 文件的名称,而不是函数名,因此对于每个模块,只能有一个函数注册为插件。

  1. ‘__module__’ 是 Python 函数的内置属性,表示函数所属的模块名。当一个函数被定义在一个模块中时,‘__module__’ 属性会被自动设置为该模块的名称。例如,在名为 ‘example.py’ 的模块中定义了一个名为 ‘func’ 的函数,则 ‘func.__module__’ 属性的值为 ‘example’。Python 函数还有一些其他内置属性,如:‘__name__’ 表示函数的名称;‘__doc__’ 表示函数的文档字符串;‘__dict__’ 是表示函数的命名空间的字典,包括函数的属性;‘__globals__’ 是表示函数全局命名空间的字典。
  2. Python 字符串的 ‘rpartition’ 方法在字符串中从右往左查找指定的分隔符,并返回一个元组,元组包含三个元素,分别为:分隔符左侧的字符串、分隔符本身、分隔符右侧的字符串;Python 字符串的 ‘split’ 方法将字符串按照指定的分隔符分割成多个子字符串,返回一个列表。
    'A'.rpartition(".")
    # ('', '', 'A')
    'A.B.C'.rpartition(".")
    # ('A.B', '.', 'C')
    'A.B.C'.split(".")
    # ['A', 'B', 'C']
  3. Python 字典的 ‘setdefault’ 方法用于获取指定键的值,如果该键不存在,将该键和指定的默认值插入到字典中。
  4. ‘namedtuple’ 用于创建一个具有命名属性的元组,类似于一个只读的类。‘namedtuple’ 不需要像普通的元组一样通过索引访问元素,而是可以通过属性名访问,能够让代码更加简洁易读。
    from collections import namedtuple
    
    # 创建一个名为 Point 的 namedtuple 类型
    Point = namedtuple('Point', ['x', 'y'])
    p = Point(1, 2)  # 使用 Point 创建一个新的元组
    print(p.x)  # 访问元组中的属性
    # 1
    print(p.y) 
    # 2
_import():使用 ‘importlib’ 导入一个模块(注册模块中插件)

Python 的导入系统能够保证每个模块(插件)只被导入一次。 在每个模块中定义的 ‘@register’ 装饰器将会注册导入模块中的插件。 

  1. ‘functools.partial’ 用于部分应用一个函数。部分应用是指,给定一个函数和一些参数,返回一个新的函数,这个新函数的参数是原来函数的一部分参数。这个新函数可以像原来的函数一样被调用,但是它只需要提供剩余的参数。假设有一个函数 ‘add’,接受两个参数并返回它们的和,使用 ‘functools.partial’ 来创建一个新函数 ‘add_five’,这个函数的作用是把 ‘add’ 函数的第一个参数固定为 5,接受一个参数,返回这个参数和5的和。
    from functools import partial
     
     
    def add(x, y):
        return x + y
    
    add_five = partial(add, 5)
    result = add_five(3)
    print(result)  
    # 输出 8
_import_all():导入包中所有模块(注册所有插件)

应用 importlib.resources.contents() 列出包中所有文件,过滤掉其中以下划线开头的文件,对余下的所有文件执行导入(注册插件)。

6.3.3 greeter_main.py 运行流程图

附注:流程图生成脚本(执行下述代码需要安装 pycallgraph,参考:Python函数关系可视化)

#!/usr/bin/env python
from pycallgraph import PyCallGraph, Config
from pycallgraph.output import GraphvizOutput
import greeter
import random


def main():
    graphviz = GraphvizOutput()
    graphviz.output_file = 'basic.png'#图片名称

    with PyCallGraph(output=graphviz, config=Config(max_depth=4)):
        greeter.greet(plugin="howdy", name="Guido")
        greeting = random.choice(greeter.greetings())
        greeter.greet(greeting, name="Frida")
        print("[INFO]Finish")


if __name__ == '__main__':
    main()

6.3.4 serializers namespace package ver3(最终版):应用 plugins

示例2中,factory.py 中 get_serializer() 函数对转换器类(或序列化器类)的命名有严格的假设:模块名中单词首字母大写 str.title() +‘Serializer’,比如:seralizers 包的模块 ‘module_name’ 中的 serializer 的类名必须为:‘Module_NameSerializer’。通过应用示例3 中的 plugins.py,可以拜托这一约束,不必关注函数名的格式。

首先,在每个 serializer 前增加一行代码:@plugins.register,以 yaml.py 为例:

<yaml.py>

# local/serializers/yaml.py
import yaml
from serializers import plugins
from serializers.json import JsonSerializer


@plugins.register
class YamlSerializer(JsonSerializer):
    def __str__(self):
        return yaml.dump(self._current_object)
 

其次,修改 factory.py 中的 get_serializer 函数:

<factory.py> 

import plugins
 
get_serializer = plugins.call_factory(__package__)
 
 
def serialize(serializable, format):
    serializer = get_serializer(format)
    serializable.serialize(serializer)
    return str(serializer)
 

通过上述重构,转换器的行为没有任何变化,但在对转换器命名时,可以更加灵活随意。

7. Python Import System

在导入模块和包时,程序内部发生了哪些事情 -- 细节可参考官方文档 The import system — Python 3.12.3 documentation

概括来说,内部发生了三件事:查找 -> 加载 -> 绑定到命名空间。

At a high level, three things happen when you import a module (or package). The module is:

  1. Searched for
  2. Loaded
  3. Bound to a namespace

对于通常的导入,即使用 import 语句完成的导入,上述三个步骤自动发生。但是,对于使用 importlib 完成的导入,前两个步骤是自动执行的,需要手动将模块绑定到一个变量或命名空间。

from math import pi as PI
PI
# 3.141592653589793


import importlib
_tmp = importlib.import_module("math")
PI = _tmp.pi
del _tmp
PI
# 3.141592653589793

7.1 模块缓存(Module Cache)sys.modules

需要注意的一点是,即使只从模块中导入一个属性,整个模块也会被加载并执行,只不过模块的其余内容不绑定到当前名称空间,可以通过查看模块缓存(Module Cache)证明这一点。

模块缓存在 Python 导入系统中起着非常重要的作用。Python 中,‘sys.modules’ 充当模块缓存的角色,它包含对已导入的所有模块的引用。‘sys.modules’ 本质上是一个字典,它包含了当前 Python 解释器中所有已经导入的模块,每当导入一个模块时,Python 解释器会在 ‘sys.modules’ 字典中添加一个键值对,其中键是模块的名称,值是对应模块的引用。

在执行导入时,Python 首先在 sys.modules中查找模块,如果一个模块已经加载过,就不会再次加载它,这一点很有必要,因为如果每次导入模块时都重新加载,那么在某些情况下可能会出现不一致的情况,且会降低程序执行效率。上文 3.2 节已经介绍过 Import Path 导入路径,它告诉 Python 在哪里搜索模块,然而,如果 Python 在模块缓存中找到某个模块,它就不会为该模块搜索导入路径。

import sys
from math import pi as PI
PI
# 3.141592653589793

sys.modules["math"].cos(PI)
# -1.0

示例:基于 Import 实现单例类 Singletons

Singletons (单例类)指只能创建一个对象(或者叫实例)的类,Python 单例类有很多实现方法,一种简单的方式是利用导入机制:完全可以信任模块缓存只导入模块一次(将实例化类的代码写在模块中,从而保证只实例化一个类一次)。

针对 5.2 节的数据,要求:创建一个单例类,能够读取数据文件内容。

<population.py>

# population.py
import csv
from importlib import resources
import matplotlib.pyplot as plt


class _Population:
    def __init__(self):
        """Read the population file"""
        self.data = {}
        self.variant = "Medium"

        print(f"Reading population data for {self.variant} scenario")
        with resources.files("data").joinpath("WPP2022_TotalPopulationBySex.csv").open(encoding='utf-8') as fid:
            rows = csv.DictReader(fid)

            # Read data, filter the correct variant
            for row in rows:
                if int(row["LocID"]) >= 900 or row["Variant"] != self.variant:
                    continue

                country = self.data.setdefault(row["Location"], {})
                population = float(row["PopTotal"]) * 1000
                country[int(row["Time"])] = round(population)

    def get_country(self, country):
        """Get population data for one country"""
        data = self.data[country]
        years, population = zip(*data.items())
        return years, population

    def plot_country(self, country):
        """Plot data for one country, population in millions"""
        years, population = self.get_country(country)
        plt.plot(years, [p / 1e6 for p in population], label=country)

    def order_countries(self, year):
        """Sort countries by population in decreasing order"""
        countries = {c: self.data[c][year] for c in self.data}
        return sorted(countries, key=lambda c: countries[c], reverse=True)


# Instantiate the Singleton
population_data = _Population()

<population_main.py>

import matplotlib.pyplot as plt
import population


for country in population.population_data.order_countries(2050)[:5]:
    population.population_data.plot_country(country)

plt.legend(loc='upper left')
plt.xlabel("Year")
plt.ylabel("Population [Millions]")
plt.title("UN Population Projections")
plt.show()

population_main 运行结果(2050 年人口数前 5 的国家自 1960~2100 的人口数据):

上述代码存在一个问题:在导入时加载数据,这有可能带来副作用。更合适的做法是在需要数据的时候 ‘懒加载’ 数据,可通过使用 @property 装饰器(@property 的作用是将一个方法变成一个只读属性,可以像访问属性一样去访问这个方法)对类的初始化部分做一点修改,修改后,数据将不会在导入时加载,而是在第一次获取 _Population.data 属性值时加载。

class _Population:
    def __init__(self):
        """Read the population file"""
        self._data = {}
        self.variant = "Medium"

    @property
    def data(self):
        if self._data:
            return self._data

        print(f"Reading population data for {self.variant} scenario")
        with resources.files("data").joinpath("WPP2022_TotalPopulationBySex.csv").open(encoding='utf-8') as fid:
            rows = csv.DictReader(fid)

            # Read data, filter the correct variant
            for row in rows:
                if int(row["LocID"]) >= 900 or row["Variant"] != self.variant:
                    continue

                country = self._data.setdefault(row["Location"], {})
                population = float(row["PopTotal"]) * 1000
                country[int(row["Time"])] = round(population)
        return self._data

7.2 重新加载模块 importlib.reload

在使用交互式解释器时,有可能需要重新导入模块,比如下面这种场景:自定义模块 number 中变量 number 的值最初为24,运行脚本中已经导入了这个模块,但运行过程中,发现这个值定义的有问题,于是将 number 模块中的 number 值修改为48,但此时再执行 import number 也无济于事。

可以考虑重启 Python Kernel,这会强制清空模块缓存;但当前会话可能处于花费很长时间准备和设置的复杂阶段,重动解释器并不可行,此时,可以使用 importlib.reload() 来重新加载模块。需要注意的是,reload() 需要一个模块对象作为参数,而不是像 import_module() 那样需要一个字符串。

7.3 查找器 Finders 和加载器 Loaders

前文提过,在命名包和模块时,应尽量避免与 Python 标准库或内置模块重名,否则导入的内容有可能与预期不符。下面做一个实验:在 Import Path 下,自定义 random.py 和 math.py 两个文件,并尝试导入,结果 random 导入的是本地文件(打印了 ‘Fake Random’)而 math 导入的是标准库中的 math 模块。

Python Import 机制

为了解释上述现象,有必要深入理解 Python 导入系统的工作原理。

导入模块时涉及以下步骤:

  1. 检查模块缓存中是否存在模块,如果 sys.modules 包含模块的名称,则该模块已经可用,导入过程结束。
  2. 使用几个查找器 finders 查找模块。查找器将使用给定的策略搜索模块,查找器对象按照优先级排序,Python 解释器会按照列表中的顺序依次调用它们的 ‘find_module()’ 方法查找指定的模块。可以使用 sys.meta_path 查看导入过程中调用了哪些查找器。
  3. 使用加载器 loaders 加载模块。使用哪个加载器由定位模块的查找器决定,并由模块规范(module spec)指定。module spec 对象包含了模块的元数据信息,例如模块的名称、文件路径、导入方式等,module spec 由 finder 对象创建的,finder 对象负责在指定位置查找模块,并创建相应的 module spec 对象。

内置查找器 BuiltinImporter 在查找本地模块的导入路径查找器 PathFinder 之前被调用,math 在内置模块列表中,因此结果如上。内置模块被编译到 Python 解释器中,通常是基本模块,如 builts、sys 和 time,哪些模块被内置取决于 Python 解释器,可以通过 sys.builtin_module_names 查看。

_distutils_hack.DistutilsMetaFinder 查找器此处没有用到,它能够在 Python 3.x 版本中使用 distutils 模块安装的第三方包中查找模块。在 Python 2.x 版本中,有一些第三方包的安装需要使用distutils 模块进行安装。但是,在 Python 3.x 版本中,distutils 模块已经被替换为 setuptools 模块。为了兼容 Python 2.x 版本的代码,Python 3.x 版本中的一些第三方包可能会使用_distutils_hack 模块来实现兼容性。

自定义查找器 -- 扩展 Python 导入系统功能

查找器必须实现 .find_spec() 类方法,该方法尝试查找给定的模块,有三种可能的返回值:

  1. 不知道如何查找和加载模块,返回 None。
  2. 返回模块规范 module.spec 指定如何加载模块。
  3. 引发 ModuleNotFoundError 指示模块无法导入。

自定义 Finder

下面举两个自定义 Finder 的例子:DebugFinder 将一条消息打印到控制台,提示导入的模块的名字(能够列举某模块所依赖的模块),显式返回 None,以指示需要其他查找器弄清楚如何实际导入模块。BanFinder 用于禁用指定模块,引发 ModuleNotFoundError 是为了确保查找器列表后面的查找器不会继续执行查找,从而保证禁用。

<custom_importer.py>

import sys
from importlib.abc import MetaPathFinder


class DebugFinder(MetaPathFinder):
    @classmethod
    def find_spec(cls, fullname, path, target=None):
        print(f"Importing {fullname!r}")
        return None


BANNED_MODULES = {"logging"}


class BanFinder(MetaPathFinder):
    @classmethod
    def find_spec(cls, fullname, path, target=None):
        if fullname in BANNED_MODULES:
            raise ModuleNotFoundError(f"{fullname!r} is banned")


sys.meta_path.insert(0, DebugFinder)
sys.meta_path.insert(1, BanFinder)
# sys.modules.clear()



<study_main.py>

import custom_importer
import sys
import pandas as pd


print(sys.meta_path)
 

运行结果:

注意:当 Python 解释器开始执行时,它会自动加载一些内置的模块,如 sys 、 os 、 re 等,这些模块会被存储在 sys.modules 中,以便在需要时进行快速访问,因此 sys.modules 在一开始就不是空的。使用 BanFinder 时,如果没有达到预期效果,可以检查一下需要禁用的模块是否已经存在于模块缓存中。

自定义查找器 -- 自动安装缺失包

PyPI · The Python Package Index

PyPI (Python Package Index)是一个用于存储 Python 写成的软件包的软件存储库,用户可以通过 PyPI 下载超过235,000个 Python 软件包,一些软件包管理器例如 pip,就是默认从 PyPI 下载软件包。

对于大多数生产环境,需要保持对环境的控制,多数情况下,让 Python 自动安装包或模块并不是很好的做法,因此,本节内容只适用于不介意删除或重新安装的开发环境。此外,模块的导入名称有可能与它在 PyPI 上的名称不一致,导致安装错误,应用时需要注意这一点。

<pip_importer.py>

# pip_importer.py
from importlib import util
from importlib.abc import MetaPathFinder
import subprocess
import sys


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

        return util.find_spec(fullname)


sys.meta_path.append(PipFinder)

<study_main.py>

import pip_importer
import sys
import parse

将 PipFinder 查找器放在查找列表的最后,如果调用 PipFinder,说明无法在系统上找到该模块,.find_spec() 将执行 pip 安装,如果安装成功,创建并返回模块规范。

运行结果:

自定义查找器和加载器 -- 像导入模块一样导入 csv 文件

这一小节将实现一个可以直接导入CSV文件的自定义加载器。为了便于演示,此处使用的是自定的文件大小较小的 temp.csv 文件:

<temp.csv>

A,B,C,D
11,21,31,41
12,22,32,42

<csv_importer.py>

# csv_importer.py

import csv
import pathlib
import re
import sys
from importlib.machinery import ModuleSpec


class CsvImporter:
    def __init__(self, csv_path):
        """Store path to CSV file"""
        self.csv_path = csv_path

    @classmethod
    def find_spec(cls, fullname, path, target=None):
        """Look for CSV file"""
        package, _, module_name = fullname.rpartition(".")
        csv_file_name = f"{module_name}.csv"
        directories = sys.path if path is None else path
        for directory in directories:
            csv_path = pathlib.Path(directory) / csv_file_name
            if csv_path.exists():
                return ModuleSpec(fullname, cls(csv_path))

    def create_module(self, spec):
        """Returning None uses the standard machinery for creating modules"""
        return None

    def exec_module(self, module):
        """Executing the module means reading the CSV file"""
        # Read CSV data and store as a list of rows
        with self.csv_path.open() as fid:
            rows = csv.DictReader(fid)
            data = list(rows)
            fieldnames = tuple(_identifier(f) for f in rows.fieldnames)

        # Create a dict with each field
        values = zip(*(row.values() for row in data))
        fields = dict(zip(fieldnames, values))

        # Add the data to the module
        module.__dict__.update(fields)
        module.__dict__["data"] = data
        module.__dict__["fieldnames"] = fieldnames
        module.__file__ = str(self.csv_path)

    def __repr__(self):
        """Nice representation of the class"""
        return f"{self.__class__.__name__}({str(self.csv_path)!r})"


def _identifier(var_str):
    """Create a valid identifier from a string

    See https://stackoverflow/a/3305731
    """
    return re.sub(r"\W|^(?=\d)", "_", var_str)


# Add the CSV importer at the end of the list of finders
sys.meta_path.append(CsvImporter)

<study_main.py>

import csv_importer
import temp


print(dir(temp))
print(temp)
print(temp.fieldnames)
print(temp.A)
print(temp.B)
print(temp.data)

csv_importer 代码解释:

  1. find_spec 类方法 将尝试在给定的路径中找到 csv 文件。参数 name 为导入模块的全称,如果使用 from data import temp,那么 name 将是 data.temp,因此需要使用 rpatition(".") 分离出文件名,然后添加 .csv 后缀,得到最终文件名。对于顶级导入,path 为 None,将在完整导入路径(含有当前工作目录)中查找 csv 文件;如果在包中导入 csv 文件,那么 path 将被设置为包的路径。如果找到匹配的 csv 文件,返回一个模块规范 ModuleSpec 对象,该对象包含模块的名称和一个 CsvImporter 实例,告诉 Python 使用 CsvImporter 加载模块。。
  2. exec_module 方法用于执行模块代码(如果模块是一个包,则该方法应该执行包的 __init__.py文件)。当前 CsvImporter.exec_module 读取 CSV 文件并将其内容转换为 Python 字典的形式,将其添加到模块的 __dict__ 中。此外,还向模块添加了一些额外的属性,如 data 和 fieldnames。
  3. _identifier 是一个辅助函数,用于对列名的规范化处理,确保索引以字母或下划线开头。
  4. __repr__ 方法用于返回一个对象的字符串表示形式。它在调试和开发过程中非常有用,它可以帮助开发者快速查看一个对象的状态和属性。
  5. 把 CsvImporter 类添加到 sys.meta_path 的末尾,Python 解释器在查找模块时会尝试使用 CsvImporter 查找器。

运行结果:

如果把 temp.csv 放在 data 包中,CsvImporter.find_spec 增加一行 path 打印,运行结果:

8. 技巧

8.1 应用 try ... except ...

Case1:不同 Python 版本使用不同包

背景:Python 3.7 引入importlib.resources,用于访问包内的资源文件。如果 Python 版本大于等于 3.7,可以使用标准库中的 importlib.resources,否则只能使用第三方库 importlib_resources($ python -m pip install importlib_resources)。

try:
    from importlib import resources
except ModuleNotFoundError:
    import importlib_resources as resources
import sys
if sys.version_info >= (3, 7):
    from importlib import resources
else:
    import importlib_resources as resources

Case2:能成功导入包A则导入包 A 否则导入包 B

背景:ujson 是 Python 的一个第三方库,它是一个高性能的 JSON 解析器和编码器,速度比标准库中的 json 模块要快得多,期望:如果环境中能使用 ujson 则用 ujson,否则使用 json。

try:
    import ujson as json
except ModuleNotFoundError:
    import json

Case3:定义假对象 Mock,缺少依赖包时以简化方式运行

关于包 colorama

Colorama 是 Python 的一个第三方库,它可以让在命令行中输出的文本拥有不同的颜色和样式,让输出更加美观和易读。在使用 colorama 模块时,需要在终端中运行 Python 脚本才能看到效果,因为 colorama 模块只能控制终端输出的样式,而不能控制其他输出方式(比如文件或者网络输出)。

  1. colorama.init(autoreset=True) 表示颜色指令将在字符串的末尾自动重置,即每次调用仅改变当前输出内容;如果希望所有的输出都采用当前设置,可以将 autoreset 设置为 False,需要恢复默认颜色样式时,使用 Style.RESET_ALL 重置颜色和样式设置。

  2. Fore 和 Back 分别代表前景色和背景色,可以用于设置文本的颜色,如:设置前景色为红色,背景色为绿色,代码为: print(Fore.RED + Back.GREEN + "Hello, world!")

  3. colorama.Cursor 提供了一些方法来控制终端光标的位置:UP(n) 表示将光标向上移动 n 行;DOWN(n) 表示将光标向下移动 n 行;FORWARD(n) 表示将光标向前移动 n 列;BACK(n) 表示将光标向后移动 n 列;SET_POS(x, y) 表示将光标移动到指定的行列位置。
import time
import colorama
from colorama import Fore, Back, Style, Cursor

colorama.init(autoreset=True)
print(f"{Fore.RED}" + "Hello")
print(f"{Back.RED}" + "Hello")
print("Hello")
print(f"{Fore.BLUE}{Back.YELLOW}{Style.BRIGHT}" + "Hello")
print(f"{Style.DIM}" + "Hello")
print(sorted(item for item in dir(Fore) if not item.startswith("_")))

# 倒计时程序
countdown = [f"{Fore.BLUE}{n}" for n in range(10, 0, -1)]
countdown.append(f"{Fore.RED}Lift off!")

print(f"{Fore.GREEN}Countdown starting:\n")
for count in countdown:
    time.sleep(1)
    print(f"{Cursor.UP(1)}{count} ")

返回结果:

倒计时部分,因为控制光标每次上移一行,倒计时数字一直在同一行显示, ‘覆盖’ 前一秒的数据。

如果不使用 Cursor,最后一行代码改为:print(f"{count} "),返回结果:

定义假对象

给控制台输出添加颜色看起来很酷,但这并不是必要的,为了避免给程序增加依赖项,希望程序最终效果是:Colorama 可用时使用 Colorama 美化输出;不可用时不会影响程序的运行。

# optional_color.py

try:
    from colorama import init, Back, Cursor, Fore, Style
except ModuleNotFoundError:
    from collections import UserString

    class ColoramaMock(UserString):
        def __call__(self, *args, **kwargs):
            return self

        def __getattr__(self, key):
            return self

    init = ColoramaMock("")
    Back = Cursor = Fore = Style = ColoramaMock("")


print(f"{Fore.RED}" + "Hello")
print(f"{Back.RED}" + "Hello")
print("Hello")
print(f"{Fore.BLUE}{Back.YELLOW}{Style.BRIGHT}" + "Hello")
print(f"{Style.DIM}" + "Hello")

ColoramaMock 类继承自 UserString,有两个特殊方法:

  1. __call__:允许类的实例像函数一样被调用,并返回实例本身。
  2. __getattr__:尝试使用 ColoramaMock 实例作为函数或访问其属性时,返回实例本身,不会执行任何实际操作。

当无法导入 colorama 时,创建 ColoramaMock 实例:ColoramaMock(""),一个空字符串,并将其赋值给 init、Fore、Back、Cursor,在后续代码中用它替代真实的 Fore、Back 。根据 ColoramaMock 的定义,调用它或它的的属性时将返回空字符串,且不会执行任何操作,因此不会影响程序运行。

返回结果: 

8.2 循环引用

假设有两个互相引用的模块 yin 和 yang,文件内容如下:

运行 import yin 结果:

代码没有报错,yang 是在导入 yin 的中间导入的,对应 yin.py 中 import yang 语句的位置。没有导致无限递归的原因是模块缓存:运行 import yin 时,在 yin 完成加载之前,就会将对 yin 的引用添加到模块缓存中,当 yang 稍后尝试导入 yin 时,使用的是模块缓存中的引用。

如果 yin 和 yang 中定义了变量和函数,也不会产生问题:

但如果当在导入时,实际使用其他模块,而不是仅仅定义稍后再使用其他模块的函数时,就会出现递归导入相关的问题。尝试在 yang.py 加一行 print(combine()) ,再执行 import yin 时就会产生报错,这是因为在导入 yang 时,yin 还没有定义 number。

总结

通常建议在设计模块时,就应避免让两个或多个模块之间相互导入。如果在模块设计阶段看到循环引用,请尝试修复打破循环。如果不得已必须要使用循环导入,请确保模块在导入时不会产生副作用,可以在模块中只定义属性、函数、类等,这样就不会产生问题。如果需要使用循环引用并且可能存在副作用,还有一种解决方法:在函数内部进行本地导入。

代码

执行下述代码,将自动生成 yang.py、yin.py、yin_yang_main.py 文件。

<helper_make_yin_yang.py>


path_list = [
    r'yin.py',
    r'yang.py',
    r'yin_yang_main.py',
]
text_list = [
    """# yin.py

print(f"Hello from yin")
import yang
print(f"Goodbye from yin")
    """,
    """# yang.py

print(f"Hello from yang")
import yin
print(f"Goodbye from yang")
    """,
    """import yin
    """
]
# 创建py文件
for path, text in zip(path_list, text_list):
    with open(path, 'w', encoding='utf-8') as f:
        f.write(text)

8.3 统计各个包的导入时间开销

Python 3.7 支持 -X importtime 命令行选项,该选项能够统计并打印每个模块导入所需的时间(含内部依赖模块导入时间),帮助开发者快速了解各个包和模块导入耗时情况。

参考链接

python相对导入常见问题和解决方案_importerror: attempted relative import with no kno-CSDN博客

Factory Method - Python Design Patterns - GeeksforGeeks

本文标签: 基础Pythonimport