SPEC 1 — 子模块和函数的延迟加载

作者:
Stéfan van der Walt <stefanv@berkeley.edu>,Jon Crall <jon.crall@kitware.com>,Dan Schult <dschult@colgate.edu>,Jarrod Millman <millman@berkeley.edu>
讨论:
https://discuss.scientific-python.org/t/spec-1-lazy-loading-for-submodules/25
历史:
https://github.com/scientific-python/specs/commits/main/spec-0001
获得认可:
ipythonnetworkxscikit-imagescipy

描述#

本 SPEC 建议一种针对库的延迟加载机制,该机制可以避免导入速度下降,并提供明确的子模块导出。

例如,它允许以下行为

import skimage as ski  # cheap operation; does not load submodules

ski.filters  # cheap operation; loads the filters submodule, but not
             # any of its submodules or functions

ski.filters.gaussian(...)  # loads the file in which gaussian is implemented
                           # and calls that function

这有几个优点

  1. 它公开了一个**表现为扁平命名空间的嵌套命名空间**。这避免了必须仔细导入正确的子模块组合,并允许在交互式终端中交互式地探索命名空间。

  2. 它**避免了必须优化导入成本**。目前,开发人员经常将导入移动到函数内部,以避免减慢模块导入速度。当在整个库中实现延迟导入时,所有导入都变得廉价。

  3. 它提供了**对子模块的直接访问**,避免了本地命名空间冲突。与其使用import scipy.linalg as sla来避免覆盖本地linalg,现在可以导入每个库并直接访问其成员:import scipy; scipy.linalg

核心项目认可#

认可本 SPEC 意味着原则上同意上述延迟加载的优势。

生态系统采用#

警告

我们不建议所有项目都使用延迟加载。例如,导入开销低的小型项目不需要它。当您担心子包导入时间,但又希望在例如 IPython 中使这些子包可用于交互式探索时,延迟加载非常有用。

采用本 SPEC 意味着使用lazy_loader包或任何其他机制(例如模块__getattr__)实现子包的延迟加载,以及如果需要,子包属性的延迟加载。

scikit-imageNetworkXMNE-Python已采用延迟加载。SciPy 实现延迟加载的一个子集,该子集仅延迟公开子包。napari采用了lazy_loader的一个原型实现。

徽章#

项目可以通过包含 SPEC 徽章来突出显示它们对本 SPEC 的采用。

SPEC 1 — Lazy Loading of Submodules and Functions
[![SPEC 1 — Lazy Loading of Submodules and Functions](https://img.shields.io/badge/SPEC-1-green?labelColor=%23004811&color=%235CA038)](https://scientific-python.cn/specs/spec-0001/)
|SPEC 1 — Lazy Loading of Submodules and Functions| 

.. |SPEC 1 — Lazy Loading of Submodules and Functions| image:: https://img.shields.io/badge/SPEC-1-green?labelColor=%23004811&color=%235CA038
   :target: https://scientific-python.cn/specs/spec-0001/
要使用一个徽章指示多个 SPEC 的采用,请参见此处

实现#

背景#

早期,大多数科学 Python 包都明确地导入了它们的子模块。例如,您可以执行以下操作

import scipy

scipy.linalg.eig(...)

这很方便:它具有扁平命名空间的简单性,但具有嵌套命名空间的组织性。但是,有一个缺点:导入子模块,尤其是大型子模块,会导致速度下降。

有一段时间,SciPy 有一种名为PackageLoader的延迟加载机制。它最终被放弃了,因为它经常以令人困惑的方式失败,尤其是在与交互式提示一起使用时。

此后,大多数库都停止了导入子模块,而是依靠文档来告诉用户要导入哪些子模块。

现在代码通常如下所示

from scipy import linalg
linalg.eig(...)

由于linalg子模块经常与其他库中的类似实例冲突,因此用户还会编写

# Invent an arbitrary name for each submodule
import scipy.linalg as sla
sla.eig(...)

# Import individual functions, making it harder to know where they are from
# later on in code.
from scipy.linalg import eig
eig(...)

Python 3.7 通过PEP 562引入了覆盖模块__getattr____dir__的功能。结合起来,这些功能使得可以再次提供对子模块的访问,但不会产生性能损失。

lazy_loader#

为了使项目更容易实现子模块和函数的延迟加载,我们提供了一个名为lazy_loader的实用程序库。它在https://github.com/scientific-python/lazy_loader中实现,并且可以通过pypiconda-forge安装。

用法#

例如,我们将展示如何为skimage.filters设置延迟导入。在库的主__init__.py中,指定哪些子模块是延迟加载的

import lazy_loader as lazy

submodules = [
    ...
    'filters',
    ...
]

__getattr__, __dir__, _ = lazy.attach(__name__, submodules)

然后,在每个子模块的__init__.py(在本例中为filters/__init__.py)中,指定要从哪里加载哪些函数

import lazy_loader as lazy

__getattr__, __dir__, __all__ = lazy.attach(
    __name__,
    submodules=['rank']
    submod_attrs={
        '_gaussian': ['gaussian', 'difference_of_gaussians'],
        'edges': ['sobel', 'sobel_h', 'sobel_v',
                  'scharr', 'scharr_h', 'scharr_v',
                  'prewitt', 'prewitt_h', 'prewitt_v',
                  'roberts', 'roberts_pos_diag', 'roberts_neg_diag',
                  'laplace',
                  'farid', 'farid_h', 'farid_v']
    }
)

以上等效于

from . import rank
from ._gaussian import gaussian, difference_of_gaussians
from .edges import (sobel, sobel_h, sobel_v,
                    scharr, scharr_h, scharr_v,
                    prewitt, prewitt_h, prewitt_v,
                    roberts, roberts_pos_diag, roberts_neg_diag,
                    laplace,
                    farid, farid_h, farid_v)

区别在于子模块仅在访问时加载一次

import skimage
dir(skimage.filters)  # This works as usual

此外,子模块内的函数仅在需要时加载一次

import skimage

skimage.filters.gaussian(...)  # Lazy load `gaussian` from
                               # `skimage.filters._gaussian`

skimage.filters.rank.mean_bilateral(...)  # Loaded once `rank` is accessed

一个缺点是错误或缺失的导入不再立即失败。在开发和测试期间,可以设置EAGER_IMPORT环境变量来禁用延迟加载,以便可以发现此类错误。

外部库#

lazy_loader.attach函数是设置包内部导入的替代方法。我们还提供lazy_loader.load,以便项目可以延迟导入外部库

linalg = lazy.load('scipy.linalg')  # `linalg` will only be loaded when accessed

默认情况下,导入错误会被推迟到使用时才会引发。可以使用以下方法立即引发导入错误:

linalg = lazy.load('scipy.linalg', error_on_import=True)

类型检查器#

上面显示的延迟加载有一个缺点:静态类型检查器(例如mypypyright)将无法推断延迟加载的模块和函数的类型。因此,mypy将无法检测潜在的错误,并且诸如VS Code之类的集成开发环境将无法提供代码补全。

为了解决此限制,我们提供了一种定义延迟导入的替代方法。与其在__init__.py文件中使用lazy.attach导入模块和函数,不如在__init__.pyi文件中指定这些导入——称为“类型存根”。然后,您的__init__.py文件使用lazy.attach_stub从存根加载导入。

以下是如何转换此__init__.py的示例

# mypackage/__init__.py
import lazy_loader as lazy

__getattr__, __dir__, __all__ = lazy.attach(
    __name__,
    submod_attrs={
        'edges': ['sobel', 'sobel_h', 'sobel_v']
    }
)

在与__init__.py相同的目录中添加一个类型存根(__init__.pyi)文件。类型存根在运行时会被忽略,但会被静态类型检查器使用。

# mypackage/__init__.pyi
from .edges import sobel as sobel, sobel_h as sobel_h, sobel_v as sobel_v

显式导入命名sobel as sobel由于 PEP 484 所必需的。或者,您可以手动提供__all__

# mypackage/__init__.pyi
__all__ = ['sobel', 'sobel_h', 'sobel_v']
from .edges import sobel, sobel_h, sobel_v

mypackage/__init__.py中的lazy.attach替换为对attach_stub的调用

import lazy_loader as lazy

# this assumes there is a `.pyi` file adjacent to this module
__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)

请注意,如果您使用类型存根,则需要采取其他措施将.pyi文件添加到您的 sdist 和 wheel 分发版中。有关更多信息,请参见PEP 561mypy 文档

注意事项#

到目前为止,我们只知道一个延迟加载不起作用的极端情况。当您在一个同名文件(my_func.py)中定义一个延迟加载的函数(例如my_func)**并且**运行 doctest 时,就会出现这种情况。不知何故,doctest 收集器修改了父模块的__dict__以包含my_func(模块,而不是函数),基本上绕过了延迟加载器及其提供my_module.my_func(函数)的能力。幸运的是,有一种简单的方法可以解决这个问题,并且它已经与常见做法一致:改为在_my_func.py中定义my_func(注意下划线)。

YAML 文件#

一旦实现了延迟导入接口,其他有趣的选择就会变得可用(但在lazy_loader中未实现)。例如,与其像上面那样指定子子模块和函数,不如在 YAML 文件中这样做

$ cat skimage/filters/init.yaml

submodules:
- rank

functions:
- _gaussian:
  - gaussian
  - difference_of_gaussians
- edges:
  - sobel
  - sobel_h
  - sobel_v
  - scharr

...

最终,我们希望延迟导入成为 Python 本身的一部分,但开发人员表示这不太可能1。在此期间,我们现在拥有了自行实现它的必要机制。

注释#


  1. Cannon B.,个人交流,2021 年 1 月 7 日。 ↩︎

本页内容