最近一直在折腾 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
├── README.rst
├── my_package
│ └── __init__.py
├── pyproject.toml
└── tests
├── __init__.py
└── test_my_package.py
.
└── 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 区分开来。
现在我们在 my_package 目录中创建两个文件 utils.py 和 app.py, 它们的内容分别为
utils.py
import hashlib
import json
def md5(obj):
return hashlib.md5(json.dumps(obj, sort_keys=True).encode()).hexdigest()
import hashlib
import json
def md5 ( obj ) :
return hashlib . md5 ( json . dumps ( obj , sort_keys = True ) . encode ( ) ) . hexdigest ( )
app.py
import sys
from utils import md5
def handler():
print('main sys.path')
print('\n'.join(sys.path))
return md5("abc")
if __name__ == '__main__':
handler()
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/[email protected]/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python39.zip /usr/local/Cellar/[email protected]/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9 /usr/local/Cellar/[email protected]/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/[email protected]/*** 是系统中 Python 的目录,最后一行 /Users/yanbin/.venv/lib/python3.9/site-packages 是虚拟环境中安装包的位置,如果运行 poetry install 将会把 my_package 安装到此处。
注:用 poetry run python my_package/app.py 是一样的效果
再来到 pytest 测试,在 tests 目录中创建 test_app.py 文件,内容如下
import sys
# from my_package.app import handler
def test_handler():
print('test sys.path')
print('\n'.join(sys.path))
assert 1 == 2
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/[email protected]/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python39.zip /usr/local/Cellar/[email protected]/3.9.7/Frameworks/Python.framework/Versions/3.9/lib/python3.9 /usr/local/Cellar/[email protected]/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 中的区别
再把 test_app.py 中的注释符号 # 去掉,新的 test-app.py 就是
import sys
from my_package.app import handler
def test_handler():
print('test sys.path')
print('\n'.join(sys.path))
assert 1 == 2
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 倒没事。
为了吃力的讨好两头,有几个解决办法
运行 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 在创建项目时支持 --src 参数,允许把包目录放在 src 目录下,形成的目录结构是
(.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
( . 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 文件,内容
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))
_ = 0
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 目录下,那么