Python import 报错问题

最近遇到 import python 自定义模块出现报错的问题,捣鼓了很久,终于算是比较搞清楚了,为了描述清楚,有测试项目目录如下:


项目结构

环境描述:

  • 自定义模块 pkg_a,包含两个文件 file_a_01.py,file_a_02.py(以下测试模块的测试文件都分别有两个包含模块标识的文件,不一一赘述了)
  • 自定义模块 pkg_c,属于 pkg_a 模块的子模块
  • 自定义模块 pkg_b
  • 入口文件目录 src,模拟常规的运行脚本 runner.py 以及一个仅包含一些字符串的配置文件 conf.py
  • 顶级目录入口文件 test.py
  • 以上每个文件运行都会打印出自己的文件名,方便根据输出判断导入成功与否
  • 本项目使用了 python 3.7 的虚拟环境

经常出现的使用场景:

场景一:顶级目录入口文件导入

在 test.py 文件测试了以下代码:

# 导入配置文件 src/conf.py
import src.conf

# 导入包 pkg_a
import pkg_a.file_a_02

输出:

I'm conf.py
I'm pkg_a_02

分析:

  • src 里没有包含 init.py 为什么还可以导入成功?
    原因是 python3.3 之后支持了隐式命名空间包,可以参考https://www.python.org/dev/peps/pep-0420/#specification
  • 从顶级目录可以导入包,这里为什么不需要写 sys.path.append(${项目目录})?
    test.py 处于项目的根目录,在运行此入口文件的时候,会自动把入口文件所在的文件夹目录添加到 sys.path 里面,有兴趣的同学可以在运行入口文件的时候输出 sys.path 看一下

场景二:次级目录入口文件导入

在 runner.py 文件测试了以下代码:

# 导入配置文件 conf.py
import conf

# 导入包 pkg_a
import pkg_a.file_a_02

输出:

I'm conf.py
Traceback (most recent call last):
  File "src/runner.py", line 9, in 
    import pkg_a.file_a_02
ModuleNotFoundError: No module named 'pkg_a'

分析:

  • 导入 pkg_a 为什么会报错?
import sys
for p in sys.path:
    print(p)
"""
输出:
test-python-import/src
...
"""

通过 python xxx.py 运行的时候,会把 xxx.py 所在的目录添加到 sys.path 的 0 位,可以通过输出 sys.path 观察到

从上面的输出可以看出,运行 runner.py 的时候,仅把 runner.py 对应的目录添加到了 sys.path 里面,所以 python 去找 package 的时候出现了找不到的问题。这个时候可以通过 sys.path.append('${project_pth}/test-python-import/src') 来临时解决问题,但是这里又引入了另外一些很繁琐的问题,如果有 runner_01.py,runner_02.py,runner_0n.py ... 那么每次又要再写一遍

  • pylint 出现的错误提示产生的误导
    如果这个目录是个命名空间目录,而不是包目录,那么 pylint 会出现如下图的报错,这个看起来也算正常,毕竟此时 sys.path 的确没有 pkg_a 的信息


    pylint 报错提示

那么,这个时候模拟另外一种情况,往 src 里添加一个 init.py 文件,让这个目录变成包,如下图所示:

新的目录结构

诡异的事情出现了,现在的 pylint 提示变成了找不到 conf,但是却找得到 pkg_a
新的报错提示

再次运行一下 runner.py ,此时输出,结果还是不变的找不到 pkg_a:

I'm conf.py
Traceback (most recent call last):
  File "src/runner.py", line 2, in 
    import pkg_a.file_a_02
ModuleNotFoundError: No module named 'pkg_a'

然后出现了一个会诡异的情况,我换了一个行,不报错了...我也是很醉


诡异换行之后正常

有兴趣的同学可以继续研究一下这个报错是啥问题,有一些方向:

  • pylint 对 namespace pkg 的支持还不是很好
    https://github.com/PyCQA/pylint/issues/2862
    https://github.com/PyCQA/pylint/issues/842
  • pylint init-hook
    在 .vscode/settings.json 里添加:
"python.linting.pylintArgs": [ 
        "--init-hook",
        "import sys; sys.path.insert(0, './')"
    ]

总之,不要被 pylint 误导了,上面有情况出现 pylint 没有对 pkg_a 检查出导入报错,但是还是运行的时候找不到包,所以最好还是通过运行时判断,不要相信 pylint。后续谈到的 pth 解决方案也会解决这个 pylint 的问题(但梅开二度说一次还是请不要相信 pylint,时刻记得 import 只和 python 的搜索路径有关,手动狗头

场景三:模块导入另外一个模块

在 pkg_a/file_a_01.py 测试了以下代码

from pkg_b import file_b_01
import file_a_02

print('I am pkg_a_01')

输出:

I'm pkg_a_02
Traceback (most recent call last):
  File "pkg_a/file_a_01.py", line 2, in 
    from pkg_b import file_b_01
ModuleNotFoundError: No module named 'pkg_b'

分析:

  1. 导入报错
    同样的问题,此时 python 在路径里找不到 pkg_b
  2. 相对导入问题

修改一下 pkg_a/file_a_01.py 的代码如下:

from . import file_a_02
from pkg_b import file_b_01

print('I am pkg_a_01')

输出:

Traceback (most recent call last):
  File "pkg_a/file_a_01.py", line 1, in 
    from . import file_a_02
ImportError: cannot import name 'file_a_02' from '__main__' (pkg_a/file_a_0
1.py)

这个问题出现的 __main__ 是不是很眼熟,就是我们经常会写一个代码

if __name__ == '__main__':
    pass

这个 __name__ 其实就是命名空间,当 file_a_01.py 自运行的时候,__name__ 会被设置为 __main__,所以这个相对导入会出现错误。但是如果这样写,被别的文件引用的时候就不会报错(前提是能找到 pkg_a),试试在顶级目录入口文件 test.py 导入 pkg_a/file_a_01.py

import src.conf
import pkg_a.file_a_01

输出

I'm conf.py
I'm pkg_a_02
I am pkg_b_01
I am pkg_a_01

这里也牵涉了一个平常不太注意的问题,当我们写一个包的时候,应该怎么导入该包内其他的文件呢?有兴趣的同学可以参考一下著名的包 requests 的导入写法,里面用的全部都是相对导入。当然也有另外一种写法,就是 import pkg_name.xxx,类比到上面的例子就是:

# 相对导入
from . import file_a_02
# 带包名的写法
from pkg_a import file_a_02

通过这些例子可以加强对导入的理解,本质上还是理解路径搜索

场景四:次级模块导入另外一个模块

在 pkg_c/file_c_01.py 测试了以下代码

import pkg_b

print('I am pkg_c_01')

输出

Traceback (most recent call last):
  File "pkg_a/pkg_c/file_c_01.py", line 1, in 
    import pkg_b
ModuleNotFoundError: No module named 'pkg_b'

分析

  1. 导入报错,都读到这的同学,估计都已经非常清楚导入报错问题了,子模块也是同理没什么特殊的地方

这里讨论一个常见的使用场景,次级模块导入上级模块的文件,把 pkg_c/file_c_01.py 代码修改为:

from .. import file_a_02

print('I am pkg_c_01')

输出:

Traceback (most recent call last):
  File "pkg_a/pkg_c/file_c_01.py", line 1, in 
    from .. import file_a_02
ValueError: attempted relative import beyond top-level package

再结合两个例子一起说明问题

  1. 在 test.py 进行调用
import src.conf
import pkg_a.pkg_c.file_c_01

输出

I'm conf.py
I'm pkg_a_02
I'm pkg_c_01
  1. 修改一下文件内容

pkg_a/pkg_c/file_c_01.py

print(__name__)
from ...pkg_b import file_b_01

print("I'm pkg_c_01")

test.py 保持例子 1 不变,仍然导入 file_c_01.py

import src.conf
import pkg_a.pkg_c.file_c_01

输出:

I'm conf.py
pkg_a.pkg_c.file_c_01
Traceback (most recent call last):
  File "test.py", line 7, in 
    import pkg_a.pkg_c.file_c_01
  File "/xxx/xxx/test-python-import/pkg_a/pkg_c/file
_c_01.py", line 2, in 
    from ...pkg_b import file_b_01
ValueError: attempted relative import beyond top-level package

分析:
为什么会出现 attempted relative import beyond top-level package 这个报错?
通过 .. 或者 ... 这种相对查找父级目录的导入,是相对该文件的目录(命名空间)去找的

参考例子 2 的情况:

  • file_c_01 的 __name__ 输出是 pkg_a.pkg_c.file_c_01
  • 此时如果通过 ... 这个去找的时候会超出 pkg_a 的范围,因为 .. 就已经是顶级包名 pkg_a 了,所以会报错

参考一开始那个自运行的例子:

  • __name__ 是 __main__,所以再通过 .. 去找父目录的时候,就会报错

有个博主的博文说的也比较清楚,可以参考:https://blog.csdn.net/SKY453589103/article/details/78863050

解决方案

上述通过几个实际的例子说明了一些常见的报错原因,下面来讨论一下解决办法。

先来看一下目前现网给出的比较多的解决办法,具体怎么做就不展开了,一搜一大堆:

  1. 最原始的,环境变量添加项目根目录进去
  • 这种方法比方法 2 好的就是不用写那么多冗余代码,但是可移植性太差了,不推荐
  1. 每次使用之前需要添加包的绝对路径进去 sys.path.append('${absolute_path}')
  • 这种方法应该是比较多人使用的了,但是每次都要写这堆代码到文件头上,太冗余了
  • 很容易被 IDE 格式化,比较不友好
  • 会欺骗 pylint 的检查,梅开三度再吐槽一下 pylint

本文推荐的解决方案是虚拟环境 + pth 文件解决导入问题

首先来说一下使用虚拟环境的好处(可能还有些同学没接触,就啰嗦一下)

  1. 将项目环境与系统环境隔离,这样安装包的时候不会与系统环境的包发生冲突。尤其是在公用的机器上,系统环境的冲突往往引发一堆没必要的问题
  2. python cli ,如果系统存在 python 和 python3 的时候,每次运行脚本都要 python3 xxx.py,很不利于拼写的补全(因为每次都是先出来 python 要自己补充一个 3)。如果更改 python 指向了 python3,又可能引发很多系统的问题,因为系统很多地方还是用的 python2。

具体使用参考官网,这里就不展开了
https://docs.python.org/3/tutorial/venv.html

以 python 3.7 为例子,创建好虚拟环境之后,在 venv/lib/python3.7/site-packages 下添加一个文件 myproject.pth (名字随便都可以),然后添加项目的绝对路径:
${绝对路径}/test-python-import

添加完之后,无论在项目的哪个目录运行脚本,python 都能搜到我们的根目录,这样我们导入包的时候,就可以直接通过包名导入了。例如,在 src/runner.py 这种二级目录不做额外处理是找不到根目录下 pkg_a 这种包名,在添加完路径之后,就可以搜得到。

具体参考官网:https://docs.python.org/3/library/site.html
也可以看一下 stackoverflow 的讨论:https://stackoverflow.com/questions/10738919/how-do-i-add-a-path-to-pythonpath-in-virtualenv

所以理解好 import 本质还是要理解好 python 的路径搜索问题

你可能感兴趣的:(Python import 报错问题)