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
    ├── 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 区分开来。

执行产品代码时的 sys.path

现在我们在 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 运行时的 sys.path

再来到 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 中的区别

  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 就是

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 倒没事。

同时解决产品代码的执行与 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 目录下,形成的目录结构是

(.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 目录下,那么

  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
    --src
  7. 类似 IntelliJ IDEA 这样的 IDE 在执行 Python 文件时会补上自己的 sys.path 条目,IDE
    中能执行并不代表命令行就没问题
    在这里插入图片描述
      如果对软件测试、接口测试、自动化测试、持续集成、面试经验。感兴趣  可以进到806549072,群内会有不定期的分享测试资料。还会有技术大牛,业内同行一起交流技术

你可能感兴趣的:(python,flask)