聊聊Python包和相对导入

renyuneyun 2020年05月29日(周五) 1 mins

今天尝试将一个代码库拆分成若干模块(准确用语应该是「」),目标可以说既典型又不典型:从逻辑上说,代码分为三块,其中一块是共享的。

很理所当然的,我会将其拆分为A、B、C三个目录,然后试图从A和B中导入C。于是也很理所当然的,我期望可以直接进行相对导入。然而这时候就有了问题,也是我走了许多弯路,浪费了很多时间的原因所在。

为了避免自己再犯错,也为了以后可以随手发给别人,写这么一篇简要但不减省地介绍和解释一下Python导入相关的话题。

我的需求

首先,我的大前提就是不要去魔改sys.pathPYTHONPATH环境变量。

抽象出来,我的目标目录结构大概是这样:

my_code
├── common
│   └── shared.py
├── server
│   ├── app.py
│   ├── lib1.py
│   └── lib2.py
└── user_lib
    └── lib.py

其中server/user_lib/都会用到common/包的内容。理想的模式([1])似乎是将三个包独立出来成为三个库,然后分别加入。但由于一些其他因素,目前不方便如此拆分。

特别地,server/下的app.py需要可以在任意地方执行;而user_lib/也有类似的要求(导入)。

官方标准

在进入例子之前,首先当然要提到「导入」相关行为的官方标准。不然折腾半天最后发现是自己没有正确按照标准来,那岂不冤枉?

官方标准其实不多,而且多是Python 2时代就已经建立起来的。其中最主要的是PEP 328PEP 366:PEP 328解释了Python如何处理(绝对和相对)导入以及曾经的其他建议;PEP 366介绍了后来新增的修正,主要目的是前向兼容地提供对主模块内使用相对导入的支持。

当然,还有一个关于隐式包的标准谈了一些特殊情况。但一来我的需求下没法用隐式包,二来它最终还是要按前面两个进行的。

按PEP 328的说法,导入就只分绝对导入和相对导入。绝对导入就是按完整的包路径去寻找,而相对导入就是导入相对当前包的另一个包(的内容)。 自Python 3(3.2?)开始,(除隐式包内的导入以外)相对导入便要求必须显式进行,也就是说要使用from A import B的形式——其中A是一个相对路径,也就是以.打头的包路径(如.A)。包的相对路径的规定和Unix路径的习惯类似:一个点表示当前包,两个表示上一级包,三个表示再上一级,依此类推。

那么绝对导入要怎么寻找包呢?通过sys.path。在shell中设置的PYTHONPATH这个环境变量最终会被加入sys.path变量中。同时,它还会加入一些其他的目录,比如(典型地)/usr/lib/pythonXXX/site-packages(其中pythonXXX和你的Python解释器版本相对应)。 Python会在sys.path下进行寻找,查找是否有对应的包。一般而言,就是去寻找对应的子目录。

标准看起来很简单很美好,但使用中却有一些奇奇怪怪的坑。

简单分离及错误

于是在看了标准后,很理所当然地,我按前面所说的目录结构将文件重新组织,顺便在每个目录touch __init__.py。然后我便开始逐一修改import语句,显式进行相对导入。最终形成的目录结构如下:

my_code
├── common
│   ├── __init__.py
│   └── shared.py
├── __init__.py
├── server
│   ├── app.py
│   ├── __init__.py
│   ├── lib1.py
│   └── lib2.py
└── user_lib
    ├── __init__.py
    └── lib.py

于是在server/包中,我有了这样的导入语句:

server/lib1.py

from . import lib2
from ..common import shared

server/app.py

from . import lib1

于是直接执行app.py,在导入出现报错:

ImportError: attempted relative import with no known parent package

看到这句报错的我完全摸不到头脑,因为在我的理解中这个包显然有parent package。我搜到了这篇介绍如何将包拆分的文章,里边也谈到了如何放置和导入共享的包。但我大略一看,我和它的做法一致,但它似乎没有提到会遭遇障碍的事情。我于是直接搜报错,看到了这篇译文明确说了如何处理该问题:

解决方案有两个,分别是在更上一级目录创建可执行文件来执行,和在更上一级目录通过python -m my_code.server.app执行。

仔细想想,这两个方案似乎是一样的,都是在更上一级目录中进行执行,(自动)修改执行时的变量信息,使得parent package存在。

于是理所当然地,方案2必须要用python -m XXX的形式执行,而不能是同样在更上一级目录执行python XXX

Python如何处理包名称和结构

为了更进一步理解为什么上述方案可行而我原来的方案不行,我又翻阅了更多文档进行了更多测试,最终形成了一些较为系统的认知。很奇怪的是,官方标准或官方文档并没有对这些事情进行明确提醒(或是不够易找)。当然,本节内容有部分是我个人理解,若有错误还望指正。

当一个目录下有__init__.py文件时,Python会将其当作一个包来处理。如果没有,则按隐式包来处理(参考文档即可,这里不详细讨论)。在__init__.py中我们可以控制向外暴露的包结构和内容,参考这里

按照PEP 366所说,Python识别包名称是通过__package__字符串。该变量由解释器自动设置,的内容是通过点分隔的完整包路径(或None)。

如果直接使用Python解释器去执行一个源文件(如对该文件增加可执行属性或显式调用解释器执行文件),那么__package__ == 'None',所以就会出现上述报错。而如果将其当作模块执行,如python -m my_code.server.app,则该变量会和命令中的一致,故而会被按预期设定。

我在测试中尝试过在该变量中增加完整的包路径,但发现这样无法起到预期作用,因为Python解释器依然报告不知道其父包在哪——似乎是因为不会自动回溯文件系统去寻找上级目录。其实PEP 366已经提到了这点,且进行了完整说明:若需让此方案奏效,还需要修改sys.path,使目标包可以被识别。

增加几行print可以发现,__init__.py文件是在访问包时必然会首先打开并执行的。同时可以发现,__package__仅在模块(文件)内生效,于是无法通过修改某一文件的该变量来节约掉修改其他文件的功夫。另外,__main__.py文件除了会被当作包的可执行入口外,并没有其他特殊之处,尤其不会修改任何的其他定义。

方案缺陷

然而如果本文停在这里,那么其实在一定程度上来说我其实并没有说清问题。

为什么这么说呢?因为该方案虽然可用,但在使用中有诸多不便。其中最要命的,就是必须要使用模块方式执行,也就是python -m my_code.server.app。这意味着什么呢?

这首先意味着不能直接执行文件,其次还意味着不能在其他模块/程序中导入这些包。

这就要命了,因为我折腾这么半天,其中一个目的就是希望user_lib/目录下的东西可以被别的项目使用呀。既然是在别的项目,那么当然不能在别的项目中import path.to.my_code.user_lib,而要可以import user_libimport my_code.user_lib

那么,为什么即使我的sys.path下已经可以找到my_code,但我还是不能在其他项目导入呢?还是因为向「上级」的引用:

user_lib/lib.py

from ..common import shared

当在其他项目(X)导入的时候,由于「当前」包是X的包,所以user_lib/lib.py对「上级」的寻找会出问题,无法按我们预期中的寻找到它「所在目录的上级目录」。

完善解法

既然我们已经找到问题所在,那么解决方案也呼之欲出了:让它可以找到「上级」就好了。

于是方案分为两种:

  1. 告诉它它的上级包是谁
  2. 改用绝对导入

方案一在理念上比较直接,但实操上比较麻烦,需要在每个(需要相对导入的)文件中手动修改其__package__变量为想要的值。

方案二在理念上比较麻烦,但实操上比较简单,需要将包添加到sys.path中(比如修改PYTHONPATH或进行「editable安装」),同时修改对上级的导入为绝对导入。

我比较不喜欢实操麻烦的东西,所以我最终选了方案二。而且由于我使用pipenv进行管理,所以实操中更简单了:(在修改好源代码后)只要在上级目录写一份极其简单的setup.py,然后在那执行pipenv install -e .就好了。

注意到我是在上级目录写的setup.py,而非my_code/目录。我不确定这是否是必须的,但我目前暂时没有研究setup.py,所以相当于照搬了别人例子

心得小结

这一套走下来其实花了我很多时间,尤其是不断试错的过程。究其本源,这还是因为Python是一个脚本语言,所以很多东西都是按脚本的模式走的。

上面一串下来,给我的概念就是:凡是直接暴露给用户的东西,都不应该使用相对导入;但在该东西内部,则可以相对导入到至多到此为止。

如PEP 366中所说,其实有别的更好的方法,但为了「兼容」,不得不只做出这种修改。相对而言,我反而是更宁愿放弃兼容,而将之修改得更「一致」。比如Rust 2018在模块和导入部分的新设计就很清晰,我很乐意去修改已有的代码来适应这种变更(而且只要你不编辑Cargo.toml说「我要上Rust 2018」,还可以不立即修改代码,有个缓冲阶段)——当然,我没有试过在类似场景下使用Rust,不清楚是否也有类似的坑(但按说一个编译型的语言应该不会有,毕竟大不了编译成动态链接库)。

另外,我在测试中发现,Python对包名称的处理其实就是不处理。这导致包名最好不要带短横线(-)——不然它没法写在import语句中(当然你可以通过更迂回的方法导入)。这点在一些其他语言中其实是做了不同处理的。比如说Golang直接在导入那里用字符串,绕过这个问题;Lisp系列支持用短横线做关键字,所以导入没有一点毛病;Rust似乎是会自动调整:文件系统中使用短横线(-)时,导入指令依然可以使用下划线(_)(不过我一直没细究如果文件系统中同时存在短横线和下划线两种时,导入会发生什么)。

在写作快要完成时发现,这个问题的这篇回答其实已经完整呈现了上文的所有解法。但这篇回答其实也没解释问题所在,只是说了「题目中的现象」如何产生,以及提供了解决方案。


您可以在Hypothesis上的該群組內進行評論,或使用下面的Disqus評論。