模块与包管理

到现在为止我们所有代码都写在一个 .py 文件里。真实项目怎么可能这么单调?一个中等规模项目动辄几十上百个 .py 文件,怎么组织、怎么导入、怎么发布?答案就是模块(module)包(package)。这一篇要把 import 的所有姿势、__init__.py__name__ 是什么、相对导入 vs 绝对导入、循环导入等问题一次讲清楚。

一、什么是模块

一个 .py 文件就是一个模块。模块是 Python 组织代码的最小单位。

1
2
3
4
5
6
7
8
# math_utils.py
PI = 3.14159

def add(a, b):
return a + b

def multiply(a, b):
return a * b

在另一个文件里用它:

1
2
3
4
5
# main.py
import math_utils

print(math_utils.PI)
print(math_utils.add(3, 4))

只要在同一个目录(或 PYTHONPATH 里),import 就找得到。

二、import 的四种姿势

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 导入整个模块
import math
math.sqrt(16)

# 2. 起别名
import numpy as np
np.array([1, 2])

# 3. 从模块中导入具体名字
from math import sqrt, pi
sqrt(16)
pi

# 4. 起别名的具体名字
from math import sqrt as square_root
square_root(16)

# 5. (不推荐)导入所有
from math import *
sqrt(16) # 直接用

推荐用 import 模块from 模块 import 具体名字。避免 from x import *——它会把 x 里所有公开名字塞进当前命名空间,容易冲突且不好追踪。

三、模块的搜索路径

import mymodule 时 Python 按顺序找:

  1. 当前脚本所在目录
  2. PYTHONPATH 环境变量里的目录;
  3. 标准库目录
  4. site-packages(pip 安装的第三方包)。

查看当前搜索路径:

1
2
import sys
print(sys.path)

想加自定义路径:

1
sys.path.append("/path/to/dir")     # 临时加

不过更好的做法是把代码组织成”包”或者做 pip install -e .(下面讲)。

四、什么是包

一个包含 __init__.py 的文件夹就是一个包。包可以嵌套模块和子包:

1
2
3
4
5
6
7
8
9
myproject/
├── main.py
└── mypkg/
├── __init__.py
├── utils.py
├── models.py
└── sub/
├── __init__.py
└── helper.py

在 main.py 里:

1
2
3
import mypkg.utils
from mypkg.models import User
from mypkg.sub.helper import format_

__init__.py 的作用

  1. 告诉 Python “这是一个包”(Python 3.3+ 不严格要求,但强烈建议保留);
  2. 可以在里面写”包被导入时执行的代码”;
  3. 可以定义 __all__ 控制 from pkg import * 的行为;
  4. 可以在里面”提升”子模块的名字,方便使用者:
1
2
3
4
5
6
# mypkg/__init__.py
from .utils import add
from .models import User

# 使用方:
# from mypkg import add, User ← 而不用 from mypkg.utils import add

五、绝对导入 vs 相对导入

在包内部导入其他模块,两种写法:

1
2
3
4
5
6
7
8
# mypkg/sub/helper.py

# 绝对导入
from mypkg.utils import add

# 相对导入
from ..utils import add # 上一级
from . import buddy # 同一级
  • .:当前包;
  • ..:上一级包;
  • ...:上上级包(一般用不到)。

PEP 8 建议绝对导入优先。相对导入只在包内部用,且路径较深时可以简化。

注意:相对导入只能在包内部使用,直接 python helper.py 会报错。得用 python -m mypkg.sub.helper

六、__name__if __name__ == "__main__":

每个模块都有一个 __name__ 属性:

  • 当模块被直接执行python xxx.py)时,__name__ == "__main__"
  • 当模块被 import 时,__name__ 是模块名(如 "mypkg.utils")。

利用这个特性可以让一个文件”既是模块又是脚本”:

1
2
3
4
5
6
7
8
9
# math_utils.py
def add(a, b):
return a + b

def _demo():
print(add(3, 5))

if __name__ == "__main__":
_demo()
  • python math_utils.py → 运行 _demo()
  • import math_utils → 只导入函数,不运行 _demo

这是 Python 最重要的惯用法之一,每个脚本都建议加这行

七、导入时”执行”vs “定义”

一个模块被 import 时,Python 会从头到尾执行一遍

1
2
3
4
5
# mymod.py
print("模块被加载!")
x = 42
def f():
print("f 被调用")
1
2
import mymod                    # 打印 "模块被加载!"
import mymod # 什么都不打印,Python 缓存了
  • 第一次 import 会执行整个模块;
  • 之后的 import 直接用缓存(存在 sys.modules);
  • 想强制重新加载:importlib.reload(mymod)(调试用,生产别用)。

推论:模块顶层不要写”重活”(比如连数据库、下载文件)——那会拖慢所有 import 它的地方。把重活放在函数里,被调用时才做。

八、__all__:控制 from x import *

在模块顶层写 __all__ = ["公开名字1", "公开名字2"]

1
2
3
4
5
6
# utils.py
__all__ = ["add", "multiply"]

def add(a, b): return a + b
def multiply(a, b): return a * b
def _internal(): pass # 私有

from utils import * 只会导入 addmultiply_internal 不会。

惯例:下划线开头的名字(_helper)表示”私有”,就算没写 __all__from x import * 也不会导入它们。

九、常见标准库模块

Python 标准库巨大,日常写代码经常用到的:

模块 用途
os, sys, pathlib 系统、路径、文件
re 正则表达式
datetime, time 日期时间
json, csv 数据格式
collections 高级数据结构(deque, Counter 等)
itertools, functools 迭代与函数工具
math, random, statistics 数学与随机
logging 日志
subprocess 调用外部命令
threading, multiprocessing, asyncio 并发
urllib, http 网络(更常用的是第三方 requests)
argparse 命令行参数
unittest 测试(更常用 pytest)

十、循环导入

循环导入是新手项目结构乱的时候常见的错误:

1
2
3
4
5
6
7
# a.py
import b
def foo(): b.bar()

# b.py
import a
def bar(): a.foo()

Python 加载时会陷入循环,可能报 ImportError

解决办法

  1. 重构代码——把共享的东西抽到第 3 个模块 common.py,让 a 和 b 都 import common
  2. 函数内部导入(延迟到调用时才 import):
1
2
3
def bar():
from a import foo # 这时 a 已经加载好了
foo()
  1. 只 import 模块,不 from … import 具体名字——通常能规避。

十一、包的组织结构

一个规范的项目结构大致:

1
2
3
4
5
6
7
8
9
10
11
12
13
myproject/
├── pyproject.toml # 项目配置
├── README.md
├── src/ # 源代码
│ └── mypkg/
│ ├── __init__.py
│ ├── models.py
│ ├── utils.py
│ └── cli.py # 命令行入口
├── tests/ # 测试
│ ├── test_models.py
│ └── test_utils.py
└── requirements.txt # 依赖

十二、pip install -e .:开发模式安装

写好一个包后,想在别的项目里 import 它,可以:

  1. 复制目录——粗暴不推荐;
  2. pip install . —— 装到 site-packages,修改源码后要重装;
  3. pip install -e . —— 开发模式,改源码立即生效,最常用。

需要在项目根目录准备一个 pyproject.toml

1
2
3
4
5
6
7
[project]
name = "mypkg"
version = "0.1.0"

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

然后:

1
pip install -e .

十三、常见 import 陷阱

陷阱 1:文件名与标准库冲突

1
2
3
my_project/
├── math.py # ❌ 遮蔽了标准库的 math
└── main.py

import math 在 main.py 里可能会拿到你的 math.py别用标准库同名的文件名(math、json、time、random、os 等)。

陷阱 2:直接跑相对导入的文件

1
2
3
mypkg/
├── __init__.py
└── sub.py # 里面有 from . import xxx

python mypkg/sub.py 会报 ImportError: attempted relative import。用 python -m mypkg.sub 才对。

陷阱 3:sys.path 里没有项目根

在 IDE 里没问题,命令行下报 ModuleNotFoundError。解决:

  • 在项目根目录 python -m myproject.main
  • 或者用 pip install -e .
  • 别硬改 sys.path

陷阱 4:__init__.py 里 import 太多

1
2
# __init__.py
from .heavy_module import ... # 一 import 就变得很慢

包被 import 时,__init__.py立刻执行。控制它的开销,不要 import 昂贵的东西。

陷阱 5:绝对/相对导入混用

同一文件里既有 from mypkg.x import ... 又有 from .x import ...,容易搞混。建议一个包内统一使用一种风格

十四、一个完整例子:小型 CLI 包

1
2
3
4
5
6
7
8
9
todocli/
├── pyproject.toml
├── src/
│ └── todocli/
│ ├── __init__.py
│ ├── storage.py
│ └── cli.py
└── tests/
└── test_storage.py

storage.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# src/todocli/storage.py
"""持久化 TODO 列表到 JSON 文件。"""
import json
from pathlib import Path

DEFAULT_FILE = Path.home() / ".todocli.json"


def load(path: Path = DEFAULT_FILE) -> list[str]:
if not path.exists():
return []
return json.loads(path.read_text(encoding="utf-8"))


def save(items: list[str], path: Path = DEFAULT_FILE) -> None:
path.write_text(json.dumps(items, ensure_ascii=False, indent=2),
encoding="utf-8")

cli.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# src/todocli/cli.py
"""命令行入口。"""
import sys
from . import storage # 相对导入


def main():
items = storage.load()
if len(sys.argv) < 2:
for i, t in enumerate(items, 1):
print(f"{i}. {t}")
return

cmd = sys.argv[1]
if cmd == "add":
items.append(" ".join(sys.argv[2:]))
storage.save(items)
elif cmd == "done":
idx = int(sys.argv[2]) - 1
items.pop(idx)
storage.save(items)


if __name__ == "__main__":
main()

init.py

1
2
3
4
5
# src/todocli/__init__.py
from .storage import load, save
from .cli import main

__all__ = ["load", "save", "main"]

安装:pip install -e .,运行:python -m todocli.cli add 学 Python

十五、小结与延伸阅读

  • 一个 .py 是模块,含 __init__.py 的文件夹是包;
  • import 有绝对、相对两种,包内优先绝对;
  • if __name__ == "__main__": 让文件既是模块又是脚本;
  • 模块顶层代码只在第一次 import 时执行;
  • 循环导入靠”重构 + 延迟导入”解;
  • pip install -e . 做本地开发;
  • 避免文件名与标准库冲突。

延伸阅读:

下一篇 pip 与虚拟环境 venv 我们讲怎么装第三方库和隔离项目依赖。