Python Poetry 项目中相对路径模块引用的问题
最近一直在折腾 Python 项目,通过对几个 Python 项目依赖管理与构建工具的对比,最后选择了 Poetry。它管理依赖,构建与发布包还是简单的多,不需要处理 setup.py, setup.cfg 和 Makefile 文件, 甚至都不需要了解 wheel 是什么就能往 PyPI 发布包了。
可是,别看 Poetry 的官网一直守护着一副小清新的形像,其实照样处处是坑,其中一个就是与相对引用有关的问题。我们来看下什么样的现像,最后的结论就是:在 Python 中避免使用相对路径引用,因为相对路径的上下文经常在变,然后必要时先执行
什么是相对引用与绝对路径引用,比如在一个包
我们用
项目目录为
utils.py
app.py
在命令行中执行 app.py, 假定后面的工作目录都是 /Users/yanbin/my-package
注:用
执行一下
执行
在
还有一个必须请注意的是,要是在代码中使用了像
这样的目录结构看似清晰了些,但会让包路径更复杂.
为了让
然后在每一个测试源文件中加上
如果用了
假如用
后来又发现不少第三方的包也在使用
[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。
可是,别看 Poetry 的官网一直守护着一副小清新的形像,其实照样处处是坑,其中一个就是与相对引用有关的问题。我们来看下什么样的现像,最后的结论就是:在 Python 中避免使用相对路径引用,因为相对路径的上下文经常在变,然后必要时先执行
poetry install, 甚至把入口代码拉到包外头去。什么是相对引用与绝对路径引用,比如在一个包
my_package 中有两个模块(Python 文件) app.py 和 utils, app.py 中对 utils 资源的引用可以写成from utils import md5 # 不确定 utils 是一个包还是一个模块,有点像是隐式相对路径模块引用注意 from 后面的
from .utils import md5 # 同一目录中的 utils 模块
import .utils
from ..utils import md5 # 上一级目录中的 utils 模块 (如果 utils.py 在与 app.py 上一级目录的话)
from my_package.utils import md5 # 绝对引用,总是从包名开始
. 与 ..,相对路径引用不能直接 import, 如不能 import .utils.md5我们用
poetry new my-package 命令创建一个 Python 项目,它的目录结构如下:1.
2└── my-package
3 ├── README.rst
4 ├── my_package
5 │ └── __init__.py
6 ├── pyproject.toml
7 └── tests
8 ├── __init__.py
9 └── 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
1import hashlib
2import json
3
4
5def md5(obj):
6 return hashlib.md5(json.dumps(obj, sort_keys=True).encode()).hexdigest()app.py
1import sys
2from utils import md5
3
4
5def handler():
6 print('main sys.path')
7 print('\n'.join(sys.path))
8 return md5("abc")
9
10
11if __name__ == '__main__':
12 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 文件,内容如下1import sys
2# from my_package.app import handler
3
4
5def test_handler():
6 print('test sys.path')
7 print('\n'.join(sys.path))
8 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 就是1import sys
2from my_package.app import handler
3
4
5def test_handler():
6 print('test sys.path')
7 print('\n'.join(sys.path))
8 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(.venv) $ poetry new my-package --src
2Created package my_package in my-package
3(.venv) $ tree my-package
4my-package
5├── README.rst
6├── pyproject.toml
7├── src
8│ └── my_package
9│ └── __init__.py
10└── tests
11 ├── __init__.py
12 └── 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 文件,内容1import os
2import sys
3
4sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
5_ = 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-packagepoetry 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's Blog[版权声明]
本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。