python的import问题踩坑

在Serverless平台的研发过程中,意外在python的import问题上踩坑了

大概还是对python的包管理基本原理不够了解,首先对python的包管理机制做一个总结,然后分析这一次踩坑的问题

python的包管理

Import基本语法

Python 中 import 有四种常见语法形式:

  • 语法1:导入整个模块
1
2
import random
random.randint(0, 5)
  • 语法2:从模块中导入特定函数或类
1
2
from random import randint
randint(0, 5)
  • 语法3:为导入模块取别名
1
2
import random as rd
rd.randint(0,5)
  • 语法4:从模块中导入所有(不推荐,可能造成名称冲突,代码难以维护)
1
2
from random import *
randint(0, 5)

绝对导入与相对导入

Absolute Import(绝对导入):从项目的根目录开始指定模块路径,例如

1
from package.subpackage.module import my_func

Relative Import(相对导入):使用基于当前文件所在位置的相对路径进行导入,以.代表当前目录,..代表上一层,如:

1
2
from .module import my_func
from ..subpackage.module import another_func

注意: 相对导入必须用from语法,不能写为import .module这种方式。

两种导入方法的区别与各自适用场合说明:

  • absolute import 明确具体,维护容易,但结构变化时若路径需修改则较为麻烦。
  • relative import 路径随结构改变自动适应,但相对路径可能导致不同位置执行时出错。

模块与包

模块 (module):

  • Python模块是以.py结尾的单个文件,包含函数、类或变量的定义。

  • 使用:通过import 模块名引入。

  • 示例:

    1
    2
    import math
    result = math.sqrt(9)
  • 常见内置模块如:math, os, sys, random 等。

包 (package):

  • 包含__init__.py文件的文件夹,用于整合多个相关模块。

  • 使用:import 包名.模块名 或者 from 包名 import 模块名

  • 示例目录结构:

    1
    2
    3
    4
    mypackage/
    ├── __init__.py
    ├── module1.py
    └── module2.py
  • 示例:

    1
    2
    from mypackage import module1
    module1.myfunction()
init文件

上述示例本质上还是模块的用法,包下的__init__.py,在包被import的时候会自动加载,来完成包的初始化,从而更方便的使用包

将包内一些模块的内容导入到包的命名空间供外部使用。

1
2
3
# __init__.py内容
from .module1 import function1, ClassA
from .module2 import function2

这样用户就可以很方便地使用:

1
from package import function1, ClassA, function2

即达到包的统一导出、自动加载功能,不必关心具体模块内部结构。

否则用户需明确导入具体模块位置:

1
from package.module1 import function1, ClassA

踩坑实践

在https://github.com/tedcy/python_test这个项目中,我复现了serverless平台的场景:

同时加载了两个包Script0和Script1,包内的每一级目录的__init__.py都使用from .模块名 import *的方式,允许对外导出包中的全部符号

当执行python3 test_import.py时,首先在test_import.py导入了Script0和Script1

1
2
import Script0
import Script1

包内的每一级目录的__init__.py都使用from .模块名 import *的方式,允许对外导出包中的全部符号

先导入的Script0,因此执行了Script0/__init__.py

1
from .test0 import *

从而执行Script0/test0.py中的全局代码:

1
2
3
sys.path.insert(0, os.path.abspath("Script0"))
from HUYA import ProcessJobSendMessageReq
sys.path.pop(0)

导入成功,然而在Script1/test1.py中一模一样的代码抛出了异常

1
2
3
4
5
6
7
8
9
10
11
try:
sys.path.insert(0, os.path.abspath("Script1"))
from HUYA import ProcessJobSendMessageReq1
print("Script1使用from HUYA:", ProcessJobSendMessageReq1.content())
sys.path.pop(0)
except ImportError as e:
print(f"Script1使用from HUYA:导入 ProcessJobSendMessageReq1 失败: {e}")
print(f" 当前 sys.path[0] 为:{sys.path[0]}")
print(f" 当前模块路径为: {__file__}")
print(f" 当前工作目录为: {os.getcwd()}")
print(f" 当前sys.modules['HUYA']: {sys.modules['HUYA']}")

完整的执行日志如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
导入新增模块: 
Script0: <module 'Script0' from '/root/python_test/test_import/Script0/__init__.py'>
Script0.test0: <module 'Script0.test0' from '/root/python_test/test_import/Script0/test0.py'>
Script0使用from HUYA: I am from Script0
导入新增模块:
HUYA: <module 'HUYA' from '/root/python_test/test_import/Script0/HUYA/__init__.py'>
HUYA.ProcessJobSendMessageReq: <module 'HUYA.ProcessJobSendMessageReq' from '/root/python_test/test_import/Script0/HUYA/ProcessJobSendMessageReq.py'>

-------------------------------------------------------------

Script1使用from HUYA:导入 ProcessJobSendMessageReq1 失败: cannot import name 'ProcessJobSendMessageReq1' from 'HUYA' (/root/python_test/test_import/Script0/HUYA/__init__.py)
当前 sys.path[0] 为:/root/python_test/test_import/Script1
当前模块路径为: /root/python_test/test_import/Script1/test1.py
当前工作目录为: /root/python_test/test_import
当前sys.modules['HUYA']: <module 'HUYA' from '/root/python_test/test_import/Script0/HUYA/__init__.py'>

可以发现,在Script0/test0.py执行结束以后,就导入了模块HUYA到了sys.modules的缓存中

因此继续使用from HUYA的导出方式,只会使用缓存的模块文件/root/python_test/test_import/Script0/HUYA/__init__.py

即使当前的sys.path已经把当前目录插入到了最高优先级,依然失败了

这和C++中按顺序查找include的方式是不一样的!

  • C++:

    每个文件各自独立,每次编译到#include都得重新根据路径和顺序去查找文件,每次都从头来,没有共享的缓存。(编译器处理方式)

  • Python:

    第一次导入模块(import)的时候,会根据sys.path路径像查地图一样去找,这个时候找到模块并加载到内存里一份。

    后续如果再次导入同个模块,Python就直接从内存里这一份(缓存)拿去用,不会再去磁盘重复找一次。(解释器缓存模块方式)

Script1/test1.py后续代码中,换了两种方式去导入

1
2
3
4
#相对路径导入
from .HUYA import ProcessJobSendMessageReq1
#绝对路径导入
from Script1.HUYA import ProcessJobSendMessageReq1

输出日志

1
2
3
4
5
6
7
Script1使用from .HUYA: I am Req1 from Script1
导入新增模块:
Script1.HUYA: <module 'Script1.HUYA' from '/root/python_test/test_import/Script1/HUYA/__init__.py'>
Script1.HUYA.ProcessJobSendMessageReq1: <module 'Script1.HUYA.ProcessJobSendMessageReq1' from '/root/python_test/test_import/Script1/HUYA/ProcessJobSendMessageReq1.py'>

Script1使用from Script1.HUYA: I am Req1 from Script1
导入新增模块:

这里能导入成功,是因为不管是相对路径还是绝对路径,导入的模块名都是绝对路径Script1.HUYA,从而避免了干扰的问题

在更深目录的Script1/sub_test/sub_test.py如果想引用同包下的HUYA.ProcessJobSendMessageReq1,也必须使用绝对路径

1
2
3
4
#不带路径导入
from HUYA import ProcessJobSendMessageReq1
#绝对路径导入
from Script1.HUYA import ProcessJobSendMessageReq1

输出日志

1
2
3
4
5
6
7
8
Script1.sub_test使用from HUYA:导入 ProcessJobSendMessageReq1 失败: cannot import name 'ProcessJobSendMessageReq1' from 'HUYA' (/root/python_test/test_import/Script0/HUYA/__init__.py)
当前 sys.path[0] 为:/root/python_test/test_import/Script1
当前模块路径为: /root/python_test/test_import/Script1/sub_test/sub_test.py
当前工作目录为: /root/python_test/test_import
当前sys.modules['HUYA']: <module 'HUYA' from '/root/python_test/test_import/Script0/HUYA/__init__.py'>

Script1.sub_test使用from Script1.HUYA: I am Req1 from Script1
导入新增模块:

总结

  • 不能先入为主,Python的导入方式和C++不同,虽然都可以指定查找路径的优先级,但是Python存在模块缓存,可以打印sys.modules迅速定位失败原因

  • 尽量不用不带路径的导入方式,很容易造成导入失败

  • 绝对路径和相对路径又有他们推荐的用法:

    • 包里的python文件调自己这个下属包里的其他python文件里的方法或者类的时候,可用相对路径或者绝对路径

      其中相对路径的方式更清晰,推荐使用

    • 包里的下属包A调另一个下属包B的python文件,要用绝对路径

参考资料

Python中import的用法

https://docs.python.org/zh-cn/3/reference/import.html