最近一直在折腾 Python 项目,通过对几个 Python 项目依赖管理与构建工具的对比,最后选择了 Poetry。它管理依赖,构建与发布包还是简单的多,不需要处理 setup.py, setup.cfg 和 Makefile 文件, 甚至都不需要了解 wheel 是什么就能往 PyPI 发布包了。
可是,别看 Poetry 的官网一直守护着一副小清新的形像,其实照样处处是坑,其中一个就是与相对引用有关的问题。我们来看下什么样的现像,最后的结论就是:在 Python 中避免使用相对路径引用,因为相对路径的上下文经常在变,然后必要时先执行 poetry install
, 甚至把入口代码拉到包外头去。
什么是相对引用与绝对路径引用,比如在一个包 my_package
中有两个模块(Python 文件) app.py 和 utils, app.py 中对 utils 资源的引用可以写成
from utils import md5 # 不确定 utils 是一个包还是一个模块,有点像是隐式相对路径模块引用
from .utils import md5 # 同一目录中的 utils 模块
import .utils
from ..utils import md5 # 上一级目录中的 utils 模块 (如果 utils.py 在与 app.py 上一级目录的话)
from my_package.utils import md5 # 绝对引用,总是从包名开始
注意 from 后面的 .
与 ..
,相对路径引用不能直接 import, 如不能 import .utils.md5
我们用 poetry new my-package
命令创建一个 Python 项目,它的目录结构如下:
1 2 3 4 5 6 7 8 9 |
. └── my-package ├── README.rst ├── my_package │ └── __init__.py ├── pyproject.toml └── tests ├── __init__.py └── test_my_package.py |
项目目录为 my-package
, 其中再来一个"同名"的目录 my_package
作为包名,里头是 Python 源文件。由于项目名称中用了中杠(my-package), 作为包名的就把中杠替换成下划线。如果用 poetry new my_package
的话项目目录名与包名就都是一样的了。后面部分在阅读时请注意把 .../my-package
与 .../my_package
区分开来。
执行产品代码时的 sys.path
现在我们在 my_package 目录中创建两个文件 utils.py 和 app.py, 它们的内容分别为
utils.py
1 2 3 4 5 6 |
import hashlib import json def md5(obj): return hashlib.md5(json.dumps(obj, sort_keys=True).encode()).hexdigest() |
app.py
1 2 3 4 5 6 7 8 9 10 11 12 |
import sys from utils import md5 def handler(): print('main sys.path') print('\n'.join(sys.path)) return md5("abc") if __name__ == '__main__': handler() |
在命令行中执行 app.py, 假定后面的工作目录都是 /Users/yanbin/my-package
(.venv) my-package$ python my_package/app.py
main sys.path
/Users/yanbin/my-package/my_package
/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python39.zip
/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9
/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/lib-dynload
/Users/yanbin/.venv/lib/python3.9/site-packages
这里打印出 sys.path
中包按序搜索的路径列表。/Users/yanbin/.venv
是 Python 虚拟环境的目录,中间三行 /usr/local/Cellar/python@3.9/***
是系统中 Python 的目录,最后一行 /Users/yanbin/.venv/lib/python3.9/site-packages
是虚拟环境中安装包的位置,如果运行 poetry install
将会把 my_package
安装到此处。
注:用 poetry run python my_package/app.py
是一样的效果
pytest 运行时的 sys.path
再来到 pytest 测试,在 tests
目录中创建 test_app.py
文件,内容如下
1 2 3 4 5 6 7 8 |
import sys # from my_package.app import handler def test_handler(): print('test sys.path') print('\n'.join(sys.path)) assert 1 == 2 |
执行一下 pytest
或 poetry run pytest
,之所以写成 assert 1 == 2
让该测试失败是为了打印出在 pytest 中的 sys.path
列表
(.venv) my-package $ pytest
.......
tests/test_app.py:8: AssertionError
------------------------------------------------------- Captured stdout call ------------------------------------------------------------------
test sys.path
/Users/yanbin/my-package
/Users/yanbin/.venv/bin
/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python39.zip
/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9
/usr/local/Cellar/python@3.9/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9/lib-dynload
/Users/yanbin/.venv/lib/python3.9/site-packages
=============================== short test summary info =====================================
FAILED tests/test_app.py::test_handler - assert 1 == 2
这里要注意 main sys.path
与 test sys.path
中的区别
- 在
main sys.path
中没有/Users/yanbin/my-package
, 只有/Users/yanbin/my-package/my_package
- 在
test sys.path
中没有/Users/yanbin/my-package/my_package
, 只有/Users/yanbin/my-package
- 另外,在
test sys.path
中多加了pytest
所在的目录/Users/yanbin/.venv/bin
pytest 要求显式的相对路径引用
再把 test_app.py
中的注释符号 #
去掉,新的 test-app.py
就是
1 2 3 4 5 6 7 8 |
import sys from my_package.app import handler def test_handler(): print('test sys.path') print('\n'.join(sys.path)) assert 1 == 2 |
执行 pytest
, 这时候我们将会看到编译的错误
(.venv) my-package $ pytest
......
tests/test_app.py:2: in <module>
from my_package.app import handler
my_package/app.py:2: in <module>
from utils import md5
E ModuleNotFoundError: No module named 'utils'
说是找不到模块 utils
,而在 my_package/app.py
中是通过
from utils import md5
的方式来使用 utils
模块的。要解决 pytest
的这个找不到模块 utils
的问题,可以修改 my_package/app.py
,把上面的语句改成
from .utils import md5
再运行 pytest
就能找到 utils
的模块了, pytest
这边的问题是得到了解决。
执行产品代码时不认可显式相对路径引用
可是按下个葫芦却浮起个瓢。维持上一步对 my_pacakge/app.py
的修改,回过头来执行一下 python my_package/app.py
(.venv) my-package $ python my_package/app.py
Traceback (most recent call last):
File "/Users/yanbin/my-package/my_package/app.py", line 2, in <module>
from .utils import md5
ImportError: attempted relative import with no known parent package
这至少对我来说并不陌生,试图不从包名开始用相对路径来引入模块失败。要同时把 pytest
和 python my_package/app.py
这两碗水端平,还得尝试采用绝对路径引用,需把
from .utils import md5
改成
from my_package.utils import md5
再执行 python my_package/app.py
, 期待万事大吉,可这次收到的错误是在写作本文之前始料未及的,这次说找不到 my_package
了
(.venv) my-package git:(:|) python my_package/app.py
Traceback (most recent call last):
File "/Users/yanbin/my-package/my_package/app.py", line 2, in <module>
from my_package.utils import md5
ModuleNotFoundError: No module named 'my_package'
因为 main sys.path
中包含的 /Users/yanbin/my-package/my_pacakge
, 所以在 my_package
包里边的代码反而不知道谁是 my_package
"模块"。相应的,在 test sys.path
中因为有 /Users/yanbin/my-package
,所以 pytest
倒没事。
同时解决产品代码的执行与 pytest
为了吃力的讨好两头,有几个解决办法
- 运行
poetry install
, 然后再执行python my_package/app.py
, 因为poetry install
会在/Users/yanbin/.venv/lib/python3.9/site-packages
下生成一个my_package.pth
文件,内容为
/Users/yanbin/my-package
见 用 .pth 文件附加 Python 模块搜索路径 - 把
app.py
文件挪出到.../my-package
目录下,执行python app.py
就没问题,因为此时会把app.py
所在的/Users/yanbin/my-package
目录加到sys.path
列表中去,从.../my-package
开始是可以找到my_package
包的 - 如果是在 IntelliJ IDEA 中执行
my_pacakage/app.py
也能成功,因为 IntelliJ IDEA 也把/Users/yanbin/my-pacakge
加到了sys.path
中
在 app.py
使用绝对路径引用的一个关键是 pytest
不会有问题了。
还有一个必须请注意的是,要是在代码中使用了像 from .utils import md5
这种相对引用,用 poetry publish
发布后的包被别的项目所引用后依然会出现找不到模块的现像。
Poetry new project-name --src 的问题
Poetry 在创建项目时支持 --src
参数,允许把包目录放在 src
目录下,形成的目录结构是
1 2 3 4 5 6 7 8 9 10 11 12 |
(.venv) $ poetry new my-package --src Created package my_package in my-package (.venv) $ tree my-package my-package ├── README.rst ├── pyproject.toml ├── src │ └── my_package │ └── __init__.py └── tests ├── __init__.py └── test_my_package.py |
这样的目录结构看似清晰了些,但会让包路径更复杂.
- 在
main sys.path
中附加的是路径/Users/yanbin/my-package/src/my_package
- 而在
test sys.path
中是只有与 tests 目录平级的/Users/yanbin/my-package
目录
为了让 pytest
能够被执行,并通过 IDE 的语法关,在测试代码中引入模块甚至要写成 from src.my_package.app import handler
, 这当然是不可取。或者在 tests 目录中创建 context.py
文件,内容
1 2 3 4 5 |
import os import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) _ = 0 |
然后在每一个测试源文件中加上 from .context import _
, 这样也不怎么好, IDE 处处会提示无法引入包或模块,况且前面也说过,我们的目的是要杜绝相对路径引用
如果用了 --src
参数创建的项目,更好的办法是要在执行 pytest
之前必须先运行 poetry install
。
照例总结一下
内容不是有一点儿乱,最后总结一下吧,弄了大半天,焦点还必须集中在 sys.path
,像在 Java 中时时要留意 classpath 一样。
假如用 poetry new my-package
创建的项目在 /Users/yanbin/my-package
目录下,那么
main sys.path
中附加有/Users/yanbin/my-package/my_package
- 如果在
.../my_package
中的 main 代码既要用import my_package.utils
绝对路径引用,又要能用python my_package/app.py
直接执行,还不想用poetry install
, 就只能把app.py
移到与.../my_package
外层去。
也就是说,执行入口可以移出到包外 test sys.path
中附加有/Users/yanbin/my-package
poetry install
会在虚拟环境的site-packages
下生成my_package.pth
文件,内容为/Users/yanbin/my-package
, 这使用得在产品代码运行时可用绝对路径from my_package.utils import md5
的引用形式- 避免使用相对路径的引用,相对路径中的当前路径飘忽不定
- 使用 poetry 创建项目时,最好不要在项目目录与包目录中加上
src
这一层,避免用poetry new <project-name> --src
- 类似 IntelliJ IDEA 这样的 IDE 在执行 Python 文件时会补上自己的
sys.path
条目,IDE 中能执行并不代表命令行就没问题
后来又发现不少第三方的包也在使用 from .abc import xyz
, from ..abc import xyz
的方式引入模块. 看起来杜绝使用相对路径引用的企图有些偏颇。
本文链接 https://yanbin.blog/python-poetry-relative-import-issues/, 来自 隔叶黄莺 Yanbin Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。