命令空间包

前言

我在解决用户遇到的一个 lyanna
问题时发现的一个之前不了解知识点,用本篇记录下来。

我学习 Python 的包内容时只有常规包,也就是以一个包含 __init__.py
文件的目录形式实现。以一个包含 __init__.py
文件的目录形式实现:

❯ tree regular
regular
├── __init__.py
├── a
│   └── __init__.py
└── b
    └── __init__.py

如果没有这个 __init__.py
文件就会造成导入失败 (python 2):

❯ rm regular/__init__.py

❯ ipython2
Python 2.7.16 (default, Nov  9 2019, 05:55:08)

In : import regular
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
 in ()
----> 1 import regular

ImportError: No module named regular

In : import regular.a
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
 in ()
----> 1 import regular.a

ImportError: No module named regular.a

这非常符合预期 (或者说,习惯了这种设定),不过本文说的是在 Python 3 中的效果:

❯ ipython3
Python 3.7.1 (default, Dec 13 2018, 22:28:16)

In : import regular

In : regular
Out: 

In : import regular.a

In : regular.a
Out: 

In : regular.a.DATA
Out: 'a'

In : regular.b.DATA
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
 in 
----> 1 regular.b.DATA

AttributeError: module 'regular' has no attribute 'b'

In : import regular.b

In : regular.b.DATA
Out: 'b'

也就是说,在 Python 3 下即便没有 __init__.py
也能正常 import 成功,不过模块会显示成
这样,另外是对于其子包的使用不受影响。
那么 Python 是怎么做到的呢?

命名空间包 (Namespace package)

这个特性是 Python 3.3 时引入的,PEP 链接: PEP420

一个文件夹中没有定义 __init__.py
也可以被导入的,只不过它不是以 Python 包的形式导入,而是以命名空间包 (Namespace package) 的形式被导入,所以显示成上面看到的
这样。
不过,利用命名空间包的主要价值是能导入目录分散的代码。

通过豆瓣的用法来理解

豆瓣
开源了一些 Python 的项目,其中有一些内部版本还在广泛的在各项目中使用,不过我们可以拿开源的来体验一下问题,我们先安装 2 个包吧:

❯ virtualenv venv --python=python2.7
❯ source venv/bin/activate
❯ git clone https://github.com/douban/douban-utils
❯ cd douban-utils/
❯ python setup.py install
❯ cd ../
❯ git clone https://github.com/douban/douban-sqlstore
❯ cd douban-sqlstore
❯ python setup.py install
❯ pip install mysqlclient  # douban-sqlstore依赖的MySQL-python已经不再维护,换一个
❯ cd ..

现在看看怎么导入:

❯ pip install ipython==5.2  # IPython 6.X开始只支持Python 3了
❯ venv/bin/ipython
In : from douban.sqlstore import SqlStore

In : from douban.utils import ptrans

这 2 个导入语句的代码在不同的包中,但是 douban 是共用的空间。为什么用豆瓣这么个 namespace 呢?
这个在延伸阅读链接 2,也就是 Python Cookbook 里面被提到过。如果你所在公司或者团队有大量的代码,由不同的人来分散地维护,那么可以把其中不同的部分组织为文件目录,但好的实践是能用共同的包前缀将所有组件连接起来,不是将每一个部分作为独立的包来安装。
这样是不能用一开始提到的那个目录名字为 regular 的常规包,需要使用命名空间包

命名空间包的三种风格

本文的重点啦:

pkgutil 风格

所谓风格其实就是用了那个 Python 模块或者特性实现命名空间,pkgutil 风格就是在每个子包里面的 __init__.py
里面添加如下的代码:

❯ cat pkgutil_style/a/__init__.py
__path__ = __import__('pkgutil').extend_path(__path__, __name__)

然后分别安装并进入交互模式:

❯ python pkgutil_style/a/setup.py install
❯ python pkgutil_style/b/setup.py install

setup.py 非常简单,就是取了个不冲突的包名。然后体验一下:

❯ venv/bin/ipython
In : from pkgutil_style.a import DATA

In : DATA
Out: 'aa'

In : from pkgutil_style.b import DATA

In : DATA
Out: 'bb'

pkg_resources 风格

它和 pkgutil 风格的区别就是子包里面的 __init__.py
里面添加的是如下代码:

__import__('pkg_resources').declare_namespace(__name__)

效果和上面一样。这种风格称为 setuptools-style。
上述 2 种风格在豆瓣项目中的已经体现了 (延伸阅读链接 3):

try:
    __import__('pkg_resources').declare_namespace(__name__)
except ImportError:
    from pkgutil import extend_path
    __path__ = extend_path(__path__, __name__)

naive 风格 (Python3.3+)

这是在 Python 3 时才可用的隐式的命名包的风格,也就是在命名空间下没有 __init__.py
:

❯ tree naive_style -L 2
naive_style  # 这里没有:arrow_left:
├── a
│   ├── __init__.py
│   └── setup.py
└── b
    ├── __init__.py
    └── setup.py

不过要注意,setup.py (除了明确使用 packages 列出包) 不能使用 setuptools.find_packages()
,而是要用 setuptools.find_namespace_packages()
:

❯ cat naive_style/a/setup.py
from setuptools import setup, find_namespace_packages

setup(
    name='pkg_3a',
    version='1',
    description='',
    long_description='',
    packages=find_namespace_packages(),
    zip_safe=False,
)

怎么确认一个包是不是 naive 风格呢?如果 __file__
属性为 None,那包是个命名空间:

In : import naive_style

In : import naive_style.a

In : naive_style
Out: 

In : naive_style.__file__

In : naive_style.a.__file__
Out: '/Users/dongweiming/mp/2020-01-02/naive_style/a/__init__.py'

PS: 注意这里和 Python Cookbook 里面说的不一样.

代码目录

本文代码可以在 mp
项目 找到

延伸阅读