解决 Python 导入模块错误 ModuleNotFoundError、ImportError、ValueError (深入浅出)

文章目录

    • 翻车实录
    • 到底哪种方法有效
    • 再次翻车与反思
    • 解决方案:两个坏的和一个好的

翻车实录

我们经常需要从一个文件中调用另一个文件中的 Python 代码,import 就是干这个用的,不过我想你也可能跟我一样,有时会遇到:

  • ModuleNotFoundError: No module named ...:无法定位模块

如果你像一些网上的帖子一样,用 .. 来做相对导入,你又会遇到这样的错误:

  • ImportError: attempted relative import with no known parent package:没有母包
  • ValueError: attempted relative import beyond top-level package:引用目标超出母包本身

然后你又找到了曾经风靡一时的 sys.path.append('..')sys.path.append(os.path.abspath('..')),甚至 sys.path.append(os.path.split(os.path.realpath(__file__))[0]) 之类的操作,可还是得到:

  • ModuleNotFoundError: No module named ...:无法定位模块

到底哪种方法有效

那么我们不如把网上找到的所有方法都 have a 踹:

# file: src/anotherpackage/letmetrytry.py
import sys

for p in sys.path:
    print(p)

try:
    # Way 1
    import mypackage
except:
    try:
        # Way 2
        from .. import mypackage
    except:
        try:
            # Way 3
            sys.path.append('..')
            import mypackage
        except:
            try:
                # Way 4
                import os
                sys.path.append(os.path.abspath('..'))
                import mypackage
            except:
                try:
                    # Way 5
                    sys.path.append(os.path.split(os.path.realpath(__file__))[0])
                    import mypackage
                except:
                    try:
                        # Way 6
                        sys.path.append(os.path.abspath(os.path.join(sys.path[0], '..')))
                        import mypackage
                    except:
                        print('呸!')
                    else:
                        print('way 6 succeeded')
                else:
                    print('way 5 succeeded')
            else:
                print('way 4 succeeded')
        else:
            print('way 3 succeeded')
    else:
        print('way 2 succeeded')
else:
    print('way 1 succeeded')

我们的目录结构是这样的:

- 工程目录              <- 我们在这里
  - src
    - main.py
    - tools.py
    - mypackage         <- 想引用这个模块
      - __init__.py
      - dog.py
    - anotherpackage
      - __init__.py
      - letmetrytry.py  <- 发生 import 的代码在这里

运行命令(在工程根目录下执行):

工程根目录>python src\anotherpackage\letmetrytry.py

得到输出:

工程根目录\src\anotherpackage
E:\Python37\python37.zip
E:\Python37\DLLs
E:\Python37\lib
E:\Python37
C:\用\户\目\录\AppData\Roaming\Python\Python37\site-packages
E:\Python37\lib\site-packages
E:\Python37\lib\site-packages\win32
E:\Python37\lib\site-packages\win32\lib
E:\Python37\lib\site-packages\Pythonwin
way 6 succeeded

我们发现最后一种方法成功了;由于它是最后一种方法,意味着前面的方法都失败。我们看看这个写法:

sys.path.append(os.path.abspath(os.path.join(sys.path[0], '..')))

它在 sys.path 中添加了当前 sys.path[0] 的上一级路径,其中 join 用于形成带 .. 的相对路径,abspath 用于将这个相对路径转换成绝对路径。

这里出现了第一个知识点:sys.path[0] 永远等于当前正在执行的 被执行文件 所在的目录,其实它来自于对你的执行命令 python SCRIPT_PATH 中的 SCRIPT_PATH 取绝对路径 —— 所以,只要我们要运行的那个文件位置不变,sys.path[0] 就不变,与我们运行时所处的位置无关

到这里为止,好像我们已经找到了正确答案……真的是这样吗?

再次翻车与反思

上面说了,只要被执行文件位置不变,这招就总是有效;那么如果那个文件位置改变了呢?

你可能会说:我只要保持项目内文件,相对于项目根目录的关系不变就好了啊~

那如果把项目打包成可执行文件呢?一个 EXE 根本不是目录,那工程内的路径关系也就不存在了,这种情况下又会怎样?我们来试一下:

工程根目录>nuitka --run --follow-imports src\anotherpackage\letmetrytry.py

说明:

  1. 这里的 nuitka 是 一个著名的 Python 打包器,它与 Pyinstaller 的打包方案不同:先把代码翻译成 C++,再用系统 C/C++ 编译器编译,最后再链入必要的 Python DLL 形成 EXE,因此具有更高的执行效率,如果代码中有需要高性能的操作,或者需要代码保密(Pyinstaller 很容易被逆向工程解译),则 Nuitka 是目前的最优解。
  2. --follow-imports 参数要求 Nuitka 跟踪代码中的导入操作,为必须;
  3. --run 参数表示我们打包好 EXE 后要直接执行一下,不加这个参数自己手动运行也是可以的。

于是我们得到了你一直想看到的输出:

工程根目录
E:\python37\python37.zip
E:\Python37\DLLs
E:\Python37\lib
C:\用\户\目\录\AppData\Roaming\Python\Python37\site-packages
E:\Python37
E:\Python37\lib\site-packages
E:\Python37\lib\site-packages\win32
E:\Python37\lib\site-packages\win32\lib
E:\Python37\lib\site-packages\Pythonwin
呸!

可以看到,我们打印出的 sys.path[0] 变成了 EXE 所在的位置(因为我们是在这个位置生成的 EXE),因为现在它才是被执行的文件,而 letmetrytry.py 已经不复存在了。

你可能会想到:如果我设法将我的代码在 EXE 运行时解压出来,放在正确的路径下,等运行完再删除,不就能解决所有问题了吗?

你是对的,且这件事可以用 Pyinstaller 完成:

工程根目录>pyinstaller -F src\anotherpackage\letmetrytry.py&&cd dist&&try

这次的运行输出很厉害,只有 3 行:

C:\Users\用户名\AppData\Local\Temp\_MEI270082\base_library.zip
C:\Users\用户名\AppData\Local\Temp\_MEI270082\lib-dynload
C:\Users\用户名\AppData\Local\Temp\_MEI270082
way 1 succeeded

可见我们的代码确实在运行时被解压到了一个临时文件夹中,并动态加载。

解决方案:两个坏的和一个好的

至此,我们通过实验确认:对于两个 物理路径平级 的模块,要实现 其中一个引用另一个,以下两种方法至少是能够运行的:

  1. 冻结代码:将你的工程直接用 Pyinstaller 打包,它会在运行时解压出我们的代码,此时代码中只需直接 import 即可;

  2. 修改 sys.path:将目标模块相对于 sys.path[0] 的路径添加到 sys.path,即使用例子中的方法 6:

    sys.path.append(os.path.abspath(os.path.join(sys.path[0], '..')))
    import mypackage
    

    并保持这两句所在的源文件相对于要 import 的模块路径不变。

这两个方法虽然暂时解决了问题,但都有明显的问题:

  1. 你不总需要打包你的工程,你想用的打包器也未必是 Pyinstaller;

    Nuitka 可通过参数 --include-module=mypackage 强行指定将某个模块打包进 EXE(前提是运行 nuitka 命令时,当前路径下能直接找到这个模块,否则 Nuitka 会报错)—— 也归作是解决方案 1 吧

  2. 将模块级别的操作依赖于运行时级别的操作,会带来可维护性问题,比如无法忍受工程目录的结构更改。

那么有没有更好的方法呢?答案是没有……才怪。

我们将程序的入口点放在 工程根目录\src 下:

# file: src/main.py

# 此时 sys.path[0] 是一个可以发现工程内任何模块的位置,上面的例子会在 Way 1 成功
import anotherpackage.letmetrytry

# main.py 到这里就可以结束了;也可以在这里拉起工程真正的入口点
from 包含实际入口点的模块 import main

if __name__ == '__main__':
    main()

运行 main.py:

工程根目录>python src\main.py

得到 anotherpackage.letmetrytry 的输出:

工程根目录\src
E:\Python37\python37.zip
E:\Python37\DLLs
E:\Python37\lib
E:\Python37
C:\用\户\目\录\AppData\Roaming\Python\Python37\site-packages
E:\Python37\lib\site-packages
E:\Python37\lib\site-packages\win32
E:\Python37\lib\site-packages\win32\lib
E:\Python37\lib\site-packages\Pythonwin
way 1 succeeded

Nuitka 打包,注意打包目标是 main.py:

工程根目录>nuitka --run --follow-imports src\main.py

得到输出:

工程根目录
E:\Python37\python37.zip
E:\Python37\DLLs
E:\Python37\lib
C:\用\户\目\录\AppData\Roaming\Python\Python37\site-packages
E:\Python37
E:\Python37\lib\site-packages
E:\Python37\lib\site-packages\win32
E:\Python37\lib\site-packages\win32\lib
E:\Python37\lib\site-packages\Pythonwin
way 1 succeeded

Pyinstaller 打包,注意打包目标是 main.py:

工程根目录>pyinstaller -F src\main.py&&cd dist&&main

得到输出:

C:\Users\用户名\AppData\Local\Temp\_MEI242682\base_library.zip
C:\Users\用户名\AppData\Local\Temp\_MEI242682\lib-dynload
C:\Users\用户名\AppData\Local\Temp\_MEI242682
way 1 succeeded

可见无论我们用 Python 运行,还是用两种打包器打包,并在任何位置运行,上面的例子都会在 Way 1 成功,即直接 import 目标模块。


总结一下:一个易于维护,在工程内任何位置只需直接 import,且无论怎么运行都能找到目标模块的方法就是:在一个能够发现工程内所有模块的位置,设置唯一的程序入口点(如 main.py),由它拉起整个工程的导入,我们也只从这里开始打包或运行。

这个方案归根结底是保证了 sys.path[0] 指向工程顶层路径,而无论被执行文件怎样被加载。

你可能感兴趣的:(Python,python,开发语言)