今天尝试将一个代码库拆分成若干模块(准确用语应该是「包」),目标可以说既典型又不典型:从逻辑上说,代码分为三块,其中一块是共享的。
很理所当然的,我会将其拆分为A、B、C三个目录,然后试图从A和B中导入C。于是也很理所当然的,我期望可以直接进行相对导入。然而这时候就有了问题,也是我走了许多弯路,浪费了很多时间的原因所在。
为了避免自己再犯错,也为了以后可以随手发给别人,写这么一篇简要但不减省地介绍和解释一下Python导入相关的话题。
我的需求
首先,我的大前提就是不要去魔改sys.path
或PYTHONPATH
环境变量。
抽象出来,我的目标目录结构大概是这样:
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 328和PEP 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_lib
或import my_code.user_lib
。
那么,为什么即使我的sys.path
下已经可以找到my_code
,但我还是不能在其他项目导入呢?还是因为向「上级」的引用:
user_lib/lib.py
from ..common import shared
当在其他项目(X)导入的时候,由于「当前」包是X的包,所以user_lib/lib.py
对「上级」的寻找会出问题,无法按我们预期中的寻找到它「所在目录的上级目录」。
完善解法
既然我们已经找到问题所在,那么解决方案也呼之欲出了:让它可以找到「上级」就好了。
于是方案分为两种:
- 告诉它它的上级包是谁
- 改用绝对导入
方案一在理念上比较直接,但实操上比较麻烦,需要在每个(需要相对导入的)文件中手动修改其__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評論。