Python Poetry 项目中相对路径模块引用的问题

最近一直在折腾 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 项目,它的目录结构如下:

项目目录为 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

app.py

在命令行中执行 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 文件,内容如下

执行一下 pytestpoetry 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.pathtest sys.path 中的区别

  1. main sys.path 中没有 /Users/yanbin/my-package, 只有 /Users/yanbin/my-package/my_package
  2. test sys.path 中没有 /Users/yanbin/my-package/my_package, 只有 /Users/yanbin/my-package
  3. 另外,在 test sys.path 中多加了 pytest 所在的目录 /Users/yanbin/.venv/bin

pytest 要求显式的相对路径引用

再把 test_app.py 中的注释符号 # 去掉,新的 test-app.py 就是

执行 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

这至少对我来说并不陌生,试图不从包名开始用相对路径来引入模块失败。要同时把 pytestpython 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

为了吃力的讨好两头,有几个解决办法

  1. 运行 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 模块搜索路径

  2. app.py 文件挪出到 .../my-package 目录下,执行 python app.py 就没问题,因为此时会把 app.py 所在的  /Users/yanbin/my-package 目录加到 sys.path 列表中去,从 .../my-package 开始是可以找到 my_package 包的
  3. 如果是在 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. main sys.path 中附加的是路径 /Users/yanbin/my-package/src/my_package
  2. 而在  test sys.path 中是只有与 tests 目录平级的 /Users/yanbin/my-package 目录

为了让 pytest  能够被执行,并通过 IDE 的语法关,在测试代码中引入模块甚至要写成 from src.my_package.app import handler, 这当然是不可取。或者在 tests 目录中创建 context.py 文件,内容

然后在每一个测试源文件中加上 from .context import _, 这样也不怎么好, IDE 处处会提示无法引入包或模块,况且前面也说过,我们的目的是要杜绝相对路径引用

如果用了 --src 参数创建的项目,更好的办法是要在执行  pytest 之前必须先运行  poetry install

照例总结一下

内容不是有一点儿乱,最后总结一下吧,弄了大半天,焦点还必须集中在 sys.path,像在 Java 中时时要留意 classpath 一样。

假如用 poetry new my-package 创建的项目在  /Users/yanbin/my-package 目录下,那么

  1. main sys.path 中附加有 /Users/yanbin/my-package/my_package
  2. 如果在 .../my_package 中的 main 代码既要用 import my_package.utils 绝对路径引用,又要能用 python my_package/app.py 直接执行,还不想用 poetry install, 就只能把 app.py 移到与 .../my_package 外层去。
    也就是说,执行入口可以移出到包外
  3. test sys.path 中附加有 /Users/yanbin/my-package
  4. poetry install 会在虚拟环境的 site-packages 下生成 my_package.pth 文件,内容为 /Users/yanbin/my-package, 这使用得在产品代码运行时可用绝对路径 from my_package.utils import md5 的引用形式
  5. 避免使用相对路径的引用,相对路径中的当前路径飘忽不定
  6. 使用 poetry 创建项目时,最好不要在项目目录与包目录中加上 src 这一层,避免用 poetry new <project-name> --src
  7. 类似 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

[版权声明] Creative Commons License 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments