进阶篇 / 第 9 章
第 9 章 · 模块、包与依赖管理
模块:把大文件拆成小块
在前八章里,我们的代码可能都写在同一个文件里。一旦超过 800 行,这个文件就会变得难以阅读、难以复用、难以测试。Python 提供了模块(module)来解决这个问题:一个以 .py 结尾的文件就是一个模块。
模块的价值可以概括为三点:
- 拆分:把上千行代码按职责拆成若干文件,每个文件专注于一件事。
- 复用:写好的模块可以被其他脚本、其他项目直接
import,不用复制粘贴。 - 隔离:每个模块有自己的全局命名空间(名字作用域),避免变量互相污染。
第一个模块:math_utils.py
假设我们写了一个数学工具模块。把下面内容保存为 math_utils.py:
math_utils.py · 自定义数学工具模块
"""数学工具模块:提供一组与数学计算相关的函数。
模块级文档字符串会被绑定到模块对象的 __doc__ 属性。
"""
import math
PI = math.pi # 模块级常量:约定用全大写命名
def area_circle(r: float) -> float:
"""计算圆的面积。"""
if r < 0:
raise ValueError("半径不能为负数")
return PI * r ** 2
def area_rect(width: float, height: float) -> float:
"""计算矩形的面积。"""
if width < 0 or height < 0:
raise ValueError("边长不能为负数")
return width * height
def is_prime(n: int) -> bool:
"""判断 n 是否为质数(n <= 1 不是质数)。"""
if n <= 1:
return False
if n <= 3:
return True
if n % 2 == 0:
return False
limit = int(math.isqrt(n)) # math.isqrt 返回向下取整的平方根
for i in range(3, limit + 1, 2):
if n % i == 0:
return False
return True
class Counter:
"""模块中也可以定义类——它们同样是这个模块的一部分。"""
def __init__(self, start: int = 0) -> None:
self.value = start
def __repr__(self) -> str:
return f"Counter({self.value})"
然后在同一个目录下新建 main.py 来使用它:
main.py · 导入并使用模块
import math_utils # 导入整个模块
print(math_utils.PI) # 3.141592653589793
print(math_utils.area_circle(2)) # 12.566370614359172
print(math_utils.area_rect(3, 4)) # 12.0
print(math_utils.is_prime(97)) # True
print(math_utils.is_prime(100)) # False
c = math_utils.Counter(10)
print(c) # Counter(10)
import math_utils 之后,模块名 math_utils 就成了一个指向模块对象的变量。你可以在 REPL 里输入 math_utils 直接看到它的内存地址(形如 <module 'math_utils' from './math_utils.py'>),这是确认「我到底导入了哪个文件」最快的办法。模块内的命名空间是独立的
模块最重要的特性之一:每个模块都拥有自己独立的全局命名空间。在模块里定义的变量、函数、类,不会自动出现在导入方的作用域里。
# math_utils.py
VERSION = "1.0.0"
secret = "只有我能看到"
# main.py
import math_utils
print(math_utils.VERSION) # 正常:1.0.0
print(VERSION) # NameError: name 'VERSION' is not defined
import math_utils 会把模块里所有名字「倒」进当前文件,实际上它只是把模块对象绑定到本地名字 math_utils 上。要取出里面的东西,必须写全 math_utils.XXX。import 到底做了什么:四个阶段
import 语句看起来只有一行,背后其实是一套完整的机制。理解这四个阶段,你就能解释几乎所有「为什么我的模块没有生效」的问题。
阶段一:查找(find)
Python 沿着 sys.path 这个列表逐个目录查找 math_utils.py(或包目录)。sys.path 的内容大致是:
- 运行脚本时,脚本所在目录(注意:不是你的 shell 当前工作目录!)。
- 环境变量
PYTHONPATH中列出的目录。 - 标准库目录(
lib/python3.12)。 - site-packages 中已安装的第三方包(由虚拟环境决定)。
在 REPL 里可以直接查看:
import sys
for i, p in enumerate(sys.path):
print(f"{i}: {p}")
print("---")
print(sys.prefix) # 当前解释器的前缀目录
print(sys.base_prefix)# 基础 Python 的前缀(虚拟环境中二者不同)
print(sys.executable) # 当前实际使用的解释器路径
python /path/to/main.py,那么 sys.path[0] 是 /path/to,而不是你当前所在的目录。所以「明明和 main.py 放在一起却 import 不到」通常是因为你在别的目录运行,或者用了 python -m 之外的方式启动 IDE 的解释器配置有误。阶段二:编译(compile)
找到源文件后,Python 把它编译成字节码(bytecode)。如果是第一次导入,字节码会缓存到 __pycache__/math_utils.cpython-312.pyc;下次导入时如果源文件没变(比对的是修改时间与大小),就直接加载 .pyc,跳过编译。这就是为什么第一次启动项目会明显慢一点。
可以手动触发全量编译:
# 递归编译当前目录下所有 .py,生成 __pycache__
import compileall
compileall.compile_dir(".", quiet=1, force=True)
# 运行时的编译信息
import math_utils
print(math_utils.__cached__) # 字节码缓存路径
print(math_utils.__file__) # 源文件路径
阶段三:执行(execute)
解释器在模块自己的命名空间中执行顶层代码:定义变量、定义函数、定义类。注意——模块被导入时,顶层代码会真的执行一次。这意味着写在模块顶层的 print()、文件读取、网络请求都会被执行。
# 顶层直接执行的副作用示例
import time
print("[utils] 模块正在被导入...") # 这一行在每次首次导入时都会打印
阶段四:缓存(cache)
模块对象被存入全局字典 sys.modules,键是模块名。之后无论多少次 import math_utils,只要 sys.modules 里已经有它,就直接返回缓存对象,不再重复执行顶层代码。
import sys
print("math_utils" in sys.modules) # False(还没导入)
import math_utils
print("math_utils" in sys.modules) # True
import math_utils # 再来一次,没有任何输出
print(math_utils) # <module 'math_utils' from '/.../math_utils.py'>
# 换个名字导入的其实是同一个对象
import math_utils as mu
print(mu is math_utils) # True
# 手动从缓存删除,再导入会重新执行
del sys.modules["math_utils"]
print("math_utils" in sys.modules) # False
sys.modules 缓存。在 IPython 里用 %reload(math_utils),或者 importlib.reload(math_utils),可以强制重新执行模块顶层代码。生产代码里绝不要依赖这个行为。三种导入写法的取舍
Python 提供多种导入语法,风格差异很大,选错了会带来可读性与维护性问题。
# 写法一:导入整个模块(推荐)
import math_utils
math_utils.is_prime(7)
# 写法二:给模块起别名(常用于长模块名或易混淆的名字)
import xml.etree.ElementTree as ET
ET.fromstring("<a>b</a>")
# 写法三:直接从模块导入具体名字
from math_utils import is_prime, area_circle
print(is_prime(7))
# 写法四:从包里导入子模块
from utils import math_utils
print(math_utils.PI)
# 写法五:从模块导入所有公开名字(不推荐)
from math_utils import *
print(PI) # 能用,但你根本不知道 PI 从哪来
下面用表格系统对比这几种写法。
| 写法 | 可读性 | 可测试性 | 副作用风险 | 推荐场景 |
|---|---|---|---|---|
import mod | 最好(调用处自带来源) | 好(易 monkeypatch) | 低 | 绝大多数情况 |
import mod as m | 好 | 好 | 低 | 长名缩写,如 pandas as pd |
from mod import f | 差(f 从哪来需跳转) | 差(名字被本地绑定) | 中 | 临时脚本、REPL 探索 |
from mod import * | 极差 | 极差 | 高(污染命名空间) | 仅限交互式 REPL |
import mod,不在模块顶层用 from ... import ...」?两个原因:一是可读性——看到 math_utils.is_prime() 就知道它来自哪里;二是可测试性——写单元测试时可以用 mock.patch("math_utils.is_prime") 替换掉真实实现,如果函数名被直接绑到本地,patch 就打不中了。as 的正确用法
import xml.etree.ElementTree as ET # 标准库惯例
import numpy as np # 社区惯例
import pandas as pd # 社区惯例
import matplotlib.pyplot as plt # 社区惯例
import sqlalchemy as sa # 社区惯例
# 避免的两个同名模块(很有代表性)
import random as _random # 标准库 random
# 假如你在 utils.py 里 import random,这里写 random 会指到自己的模块
random.py、logging.py、types.py 这类文件,会遮蔽(shadow)标准库同名模块。因为 sys.path[0] 优先,import logging 可能导入到你的文件,报出 AttributeError: module 'logging' has no attribute 'getLogger'。解决办法是把文件改名(如 log_utils.py),这是新手最常踩的一类 bug。__name__:模块的双重身份
每个 Python 模块在运行时都有一个内置属性 __name__。它的值取决于模块是被直接运行还是被导入:
| 场景 | 模块的 __name__ | 含义 |
|---|---|---|
python main.py | "__main__" | 这个文件是程序入口 |
python -m main | "__main__" | 同上(以模块方式运行) |
import main(被别的文件导入) | "main" | 它是库模块 |
这个机制的唯一用途,是实现「入口点守卫(entry point guard)」:把「演示用的代码」和「对外提供功能的代码」分开。
main.py · 带入口点守卫的模块
"""这个文件既是可以被导入的库,也是可以直接运行的演示脚本。"""
def add(a: int, b: int) -> int:
"""返回两数之和。"""
return a + b
def demo() -> None:
"""演示函数:只有直接运行时才会被调用。"""
print("=== math_utils 演示 ===")
print("add(3, 4) =", add(3, 4))
print("add(10, 20) =", add(10, 20))
# 入口点守卫:只有「直接运行本文件」才执行 demo()
if __name__ == "__main__":
demo()
# 情况一:直接运行
$ python main.py
=== math_utils 演示 ===
add(3, 4) = 7
add(10, 20) = 30
# 情况二:被导入
$ python -c "import main; print('导入完成')"
导入完成 # 注意:演示没有执行
random.py、json/decoder.py)、Flask、Pandas、Numpy 全都遵守。看到项目代码末尾的 if __name__ == "__main__":,它的意思永远是同一句:「下面这段是演示/CLI 入口,不是库功能」。为什么有了守卫还需要 -m
# 直接运行:sys.path[0] 是脚本所在目录
$ python scripts/train.py # sys.path[0] = /proj/scripts
# 以模块方式运行:sys.path[0] 是当前工作目录
$ python -m scripts.train # sys.path[0] = /proj
区别在于能否从项目根目录用包路径导入兄弟模块:
# scripts/train.py
from utils.config import load_config # python train.py 失败;python -m scripts.train 成功
python -m package.module 运行,而不是 python path/to/module.py。原因有两条:一是 sys.path[0] 正确,能按包路径导入;二是当项目里有同名模块时不至于把自己重复导入两次(因为此时 __name__ 是完整点分路径,与导入时一致)。包:给模块装上目录结构
当模块多起来时,全平铺在一个目录里显然不行。Python 用包(package)把相关模块组织成目录树。
什么是包
一个包含 __init__.py 文件的普通目录,就是一个包。__init__.py 可以为空(现代 Python 3.3+ 的命名空间包甚至允许没有它),但约定上应该存在。
目录树:一个典型的两层包
myproject/
├── main.py # 入口脚本
└── utils/ # 这是一个包
├── __init__.py # 包标记文件 + 包的公开接口
├── math_utils.py # 子模块
├── str_utils.py # 子模块
└── io_utils.py # 子模块
utils/__init__.py
"""工具包:把常用的小功能集中管理。
这个文件在导入 utils 包时会先被执行。
"""
# 常用的子模块可以直接在包层再导出一次,简化调用
from .math_utils import is_prime, area_circle
from .str_utils import reverse
__all__ = ["is_prime", "area_circle", "reverse"]
__version__ = "1.0.0"
def package_info() -> str:
"""返回包的元信息。"""
return f"utils v{__version__}"
main.py · 使用包
from utils import is_prime, package_info # 用包 __init__ 导出的名字
from utils.math_utils import area_circle # 用完整子模块路径
from utils.str_utils import reverse # 又一个子模块
import utils.str_utils # 用别名引用子模块
print(is_prime(101)) # True
print(area_circle(1)) # 3.141592653589793
print(reverse("python")) # nohtyp
print(package_info()) # utils v1.0.0
print(utils.__name__) # utils
print(utils.__path__) # 类似 ['/proj/utils'] 的列表
绝对导入 vs 相对导入
包内的模块互相引用时,有两种写法:
| 写法 | 示例 | 基准 | 能否在包外直接运行 |
|---|---|---|---|
| 绝对导入 | from utils.str_utils import reverse | 顶层包名 | 能(前提是根目录在 sys.path) |
| 相对导入 | from .str_utils import reverse | 当前包 | 不能(attempted relative import with no known parent package) |
| 相对导入(父包) | from ..shared import log | 上一级包 | 同上 |
# utils/math_utils.py —— 推荐用相对导入引用兄弟模块
from .str_utils import normalize # 同级:单点
from ..common.constants import PI # 上一级:两点
# 如果项目根目录在 sys.path 里,绝对导入也可以,但要求包被正确安装/识别
from utils.str_utils import normalize
python utils/math_utils.py 运行——相对导入依赖「包上下文」,直接运行时模块没有父包,会抛 ImportError: attempted relative import with no known parent package。若要测试某个子模块,请在包外面建测试文件导入它。__init__.py 里的 __all__ 有三个作用:① 明确声明哪些名字是本包的公开 API;② 决定 from utils import * 导入什么;③ 文档生成工具(如 Sphinx)据此判断对外接口。养成写 __all__ 的习惯,等于给自己和别人一份契约。规范的目录结构
目录结构是「项目质量」最直接的体现。下面是一套被广泛采用的布局,可以照抄。
一个成熟 Python 项目的目录树
myproject/
│
├── pyproject.toml # 项目元数据 + 依赖声明(现代标准,PEP 518/621)
├── README.md # 项目说明
├── LICENSE # 许可证
├── .gitignore # 忽略规则
├── .env # 环境变量(不应提交到 git)
│
├── .venv/ # 虚拟环境目录(不应提交到 git)
│
├── src/
│ └── myproject/ # 主包名(避免与项目名混用)
│ ├── __init__.py
│ ├── main.py # 程序入口
│ ├── config.py # 配置读取
│ ├── cli/ # 命令行界面子包
│ │ ├── __init__.py
│ │ └── parser.py
│ ├── core/ # 核心业务逻辑
│ │ ├── __init__.py
│ │ ├── engine.py
│ │ └── models.py
│ └── utils/ # 无业务含义的通用工具
│ ├── __init__.py
│ ├── text.py
│ └── timing.py
│
├── tests/ # 测试代码(pytest 约定)
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_engine.py
│ └── test_utils.py
│
├── docs/ # 文档
│ └── index.md
│
├── scripts/ # 一次性脚本(数据迁移、维护任务)
│ └── migrate.py
│
└── data/
└── sample.json
各目录职责对照
| 路径 | 放什么 | 不该放什么 |
|---|---|---|
src/ | 全部可被导入的源码 | 测试、数据、脚本 |
tests/ | pytest 测试函数 | 实际业务逻辑 |
scripts/ | 运维/迁移/批处理脚本 | 要被库调用的函数 |
docs/ | Markdown、配置示例 | 可执行代码 |
| 项目根 | pyproject.toml、README、.gitignore | 一堆散落的 .py |
为什么推荐 src 布局
「为什么不在根目录直接放 myproject/?」——因为在 src 布局下,import myproject 只有在你主动安装项目(pip install -e .)之后才有效。这逼迫开发者为每个项目建立独立虚拟环境并安装项目本身,从而杜绝「在我的电脑上能跑」这类问题。
# src 布局下,根目录没有 myproject,所以直接 python main.py 会失败
$ python main.py
ModuleNotFoundError: No module named 'myproject'
# 安装到虚拟环境(editable 可编辑模式)之后就正常了
$ pip install -e .
$ python main.py # OK
requests/、numpy/),也别在项目根放 test.py 之类的文件——pytest 默认收集名为 test_*.py 的文件,命名混乱会导致测试收集出错。虚拟环境:每个项目一套依赖
虚拟环境(virtual environment)是 Python 生态的基石。它的作用是为每个项目创建一份独立的解释器 + 独立的第三方包目录,从而让「项目 A 需要 requests 2.x,项目 B 需要 requests 3.x」这种事成为可能。
# 创建(在项目根目录执行,目录名约定为 .venv)
$ python -m venv .venv
# 激活(Linux / macOS)
$ source .venv/bin/activate
(.venv) $ pip list
# 激活(Windows PowerShell)
PS C:\proj> .venv\Scripts\Activate.ps1
(.venv) PS C:\proj> pip list
# 退出
$ deactivate
verify_env.py · 确认虚拟环境生效
import sys
import site
def report() -> None:
"""打印当前解释器的关键信息,用来判断是否在虚拟环境中。"""
print("解释器路径 :", sys.executable)
print("前缀 sys.prefix :", sys.prefix)
print("基础前缀 sys.base_prefix :", sys.base_prefix)
print("是否在虚拟环境中 :", sys.prefix != sys.base_prefix)
# venv 独有:sys.real_prefix 在 venv 中存在
print("real_prefix :", getattr(sys, "real_prefix", "无(说明不是 venv)"))
print("包搜索路径 :")
for p in site.getsitepackages():
print(" ", p)
if __name__ == "__main__":
report()
$ python verify_env.py
解释器路径 : /proj/.venv/bin/python
前缀 sys.prefix : /proj/.venv
基础前缀 sys.base_prefix : /usr
是否在虚拟环境中 : True
real_prefix : /usr
包搜索路径 :
/proj/.venv/lib/python3.12/site-packages
venv 与 virtualenv
| 对比项 | venv(标准库) | virtualenv(第三方) |
|---|---|---|
| 安装 | Python 3.3+ 自带 | 需 pip install virtualenv |
| 速度 | 较慢(复制解释器文件) | 快 |
| 支持老 Python | 不支持 2.7 | 支持 |
| 推荐 | ✅ 新项目首选 | 仅老项目 |
sudo pip install——那会把包装到系统目录里,等于没装进虚拟环境。如果遇到 externally-managed-environment 报错,说明你在系统 Python 上操作,先建 venv 再说。把虚拟环境纳入版本控制
# .gitignore 关键条目
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.env
dist/
build/
用 uv 加速(可选)
现代 Python 项目越来越多地使用 uv(Rust 编写)替代 pip + venv,命令几乎一一对应但快 10–100 倍:
$ uv venv # 创建 .venv
$ uv pip install requests # 安装(自动解析依赖树)
$ uv pip freeze # 导出依赖清单
$ uv run python main.py # 在虚拟环境里运行
$ uv init # 初始化项目 + 生成 pyproject.toml
pip:装包、管版本
pip 是 Python 官方的包安装工具。掌握它 80% 的日常用法就足够了。
最常用的命令
# 安装
$ pip install requests # 装最新版
$ pip install requests==2.31.0 # 装指定版本
$ pip install "requests>=2.28,<3" # 装版本区间(记得加引号)
$ pip install -U requests # 升级到最新版
$ pip install -e . # 可编辑安装当前项目(开发模式)
$ pip install -r requirements.txt # 批量安装
# 卸载
$ pip uninstall requests
# 查看
$ pip list # 列出全部已装包
$ pip show requests # 看单个包的详细信息
$ pip list --outdated # 看哪些包有新版本
# 搜索(PyPI 搜索)
$ pip index versions requests # 列出可用版本
pip install requests>=2.28,<3 里的 > 会被 shell 当成输出重定向,结果会创建一个名为 =2.28,<3 的空文件。正确写法是 pip install "requests>=2.28,<3"。pip 的三个失败模式
| 报错关键字 | 原因 | 解决 |
|---|---|---|
No matching distribution found | 包名拼错,或该包不支持当前 Python | 到 PyPI 核对包名与 Requires-Python |
externally-managed-environment | 在系统 Python 上安装 | python -m venv .venv 后重试 |
Permission denied | 混用了 sudo pip | 去掉 sudo,确认已激活虚拟环境 |
Failed building wheel | 源码包缺 C 编译器/头文件 | 优先装带 manylinux 轮子的版本;或装编译工具链 |
pip cache 能加速
$ pip cache dir # 查看缓存目录
$ pip cache info # 缓存统计
$ pip cache purge # 清理缓存(磁盘告急时)
$ pip install --no-cache-dir requests # 本次跳过缓存(怀疑缓存损坏时)
依赖管理:requirements.txt 与 pyproject.toml
依赖管理要解决的核心问题是:把你电脑上能跑的环境,完整、准确、可复现地搬到别人电脑上。
requirements.txt:最朴素的方案
# 直接依赖(你写的)
requests==2.31.0
numpy>=1.26,<2.0
pandas==2.2.0
# 注释是允许的
-e . # 以可编辑模式安装本项目
# 传递依赖一般不写,交给 pip 解析
$ pip freeze > requirements.txt # 导出当前环境全部包(含传递依赖)
$ pip freeze --local > requirements.txt # 只导出直接安装的(需要 pip 22.2+)
$ pip install -r requirements.txt # 另一台机器还原环境
requirements-dev.txt · 开发期额外依赖
-r requirements.txt # 先装运行时依赖
pytest==8.0.2
pytest-cov==4.1.0
ruff==0.1.9
mypy==1.8.0
black==23.12.1
pip freeze 会把所有传递依赖都写进去(比如 requests 拉来的 urllib3、certifi、idna)。这样做的坏处是:某个库的次要依赖升级也可能把你的环境搞坏,而且文件几百行难以阅读。团队建议:手工维护一份只含直接依赖的 requirements.txt,完整快照用 pip freeze 导出为 requirements.lock 单独存档。pyproject.toml:现代标准
从 PEP 518(2018)起,pyproject.toml 成为项目配置的统一标准。它同时承担三件事:构建配置 + 项目元数据 + 依赖声明。
pyproject.toml · 现代 Python 项目的标准配置
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "myproject"
version = "1.2.0"
description = "一个示例项目"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [
{ name = "张三", email = "zhangsan@example.com" },
]
keywords = ["python", "example", "cli"]
classifiers = [
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License",
]
# 运行时依赖
dependencies = [
"requests>=2.31.0,<3",
"rich>=13.7",
]
[project.optional-dependencies]
# 可选功能组:pip install "myproject[dev]"
dev = [
"pytest>=8.0",
"ruff>=0.1.9",
]
data = [
"pandas>=2.2",
"matplotlib>=3.8",
]
[project.scripts]
# 生成命令行入口:安装后可直接在终端敲 myproject
myproject = "myproject.cli:main"
[project.urls]
Homepage = "https://github.com/example/myproject"
Repository = "https://github.com/example/myproject"
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --cov=src"
三种依赖文件怎么选
| 文件 | 面向 | 何时更新 | 是否提交 git |
|---|---|---|---|
pyproject.toml [project.dependencies] | 包使用者 / 协作开发者 | 手动,新增依赖时 | ✅ 必须 |
requirements.txt | 应用部署(服务器、Docker) | 手动或 pip freeze | ✅ 通常提交 |
requirements.lock | 生产环境精确复现 | 发布时生成 | ✅ 建议提交 |
poetry.lock / uv.lock | 对应工具的锁定 | 自动 | ✅ 必须 |
pyproject.toml;应用/项目(自己跑的服务、脚本)建议「pyproject.toml + 锁文件」双保险:前者表达意图,后者保证可复现。从零到可安装的完整流程
# 1. 创建虚拟环境
$ python -m venv .venv
$ source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
# 2. 升级打包工具
$ python -m pip install --upgrade pip
# 3. 安装项目本体(可编辑模式)
$ pip install -e .
# 4. 安装开发依赖
$ pip install -e ".[dev]"
# 5. 验证:安装后终端里多了 myproject 命令
$ myproject --help
# 6. 构建分发包
$ python -m build # 产出 dist/*.whl 与 dist/*.tar.gz
# 7. 发布到 PyPI
$ python -m twine upload dist/*
# 8. 别人使用时
$ pip install myproject
$ pip install "myproject[data]" # 带可选功能
在代码里检查依赖版本
from importlib.metadata import version, PackageNotFoundError
def require(min_version: str = "2.0.0") -> None:
"""检查当前安装的第三方库版本是否满足要求。"""
try:
installed = version("requests")
except PackageNotFoundError:
raise SystemExit("请先安装:pip install requests")
parts = lambda v: tuple(int(x) for x in v.split(".") if x.isdigit())
if parts(installed) < parts(min_version):
raise SystemExit(f"requests 版本过低:{installed} < {min_version}")
print(f"依赖检查通过:requests {installed}")
if __name__ == "__main__":
require("2.0.0")
pkg_resources 或直接 import requests; requests.__version__ 来取版本——前者已被弃用,后者要求该包恰好已导入。importlib.metadata 是 3.8+ 起的标准做法。常见坑与最佳实践清单
导入相关
- 遮蔽标准库:不要创建
random.py、logging.py、json.py、types.py、email.py。 - 循环导入:A 导入 B、B 又导入 A。症状是
ImportError: cannot import name 'X' from partially initialized module。解法:把公共部分抽到第三个模块,或改用延迟导入(函数内部 import)。 - 忘记 flush 的脚本:模块顶层做 IO 会让导入变慢,把 IO 放进函数里。
循环导入的两种解法
# ===== 解法一:延迟导入(函数内 import)=====
# models.py
def save(record):
from .storage import write # 延迟到调用时才导入,打破循环
return write(record)
# storage.py
from .models import make_record # 这里就可以正常导入了
# ===== 解法二:抽公共层 =====
# utils/constants.py <- 谁都不依赖谁
# models.py <- from .constants import X
# storage.py <- from .constants import Y
命名与结构
| 对象 | 命名风格 | 示例 |
|---|---|---|
| 包 / 模块 | 全小写 + 下划线 | data_processing/、str_utils.py |
| 类 | 大驼峰(CapWords) | DataProcessor |
| 函数 / 变量 | 小写下划线 | load_config()、max_retry |
| 常量 | 全大写 | MAX_RETRY = 3 |
| 包内私有 | 前导下划线 | _internal.py、_cache |
检查清单
- □ 每个项目都有独立
.venv,且.venv/已加入.gitignore。 - □ 项目根有
pyproject.toml,声明了name、version、requires-python、dependencies。 - □
src/布局,开发时用pip install -e .。 - □ 模块顶层用
import mod,不用from mod import *。 - □ 每个可执行脚本都有
if __name__ == "__main__":守卫。 - □
__init__.py写清包用途并声明__all__。 - □ 敏感配置(密钥)放环境变量或
.env,不进代码库。 - □ README 写明:如何建虚拟环境、如何装依赖、如何运行、如何跑测试。
.venv 并识别 pyproject.toml,但仍然要理解上面的机制——线上部署、Docker 构建、CI 流程里没有 IDE 的照顾,只能靠你自己写对的脚本。本章小结
- 模块就是
.py文件,每个模块有独立的全局命名空间,用import复用。 - import 四阶段:在
sys.path中查找 → 编译成.pyc→ 执行顶层代码一次 → 存入sys.modules缓存。 - 导入写法:优先
import mod(或import mod as m),避免from mod import *,因为可读性与可测试性更好。 __name__:直接运行时为"__main__",被导入时为模块名;用它实现入口点守卫。- 包是含
__init__.py的目录;包内优先用相对导入(from . import x),并在__init__.py用__all__声明公开 API。 - 目录结构:
src/包名/+tests/+scripts/+pyproject.toml,venv 与缓存目录不进版本库。 - 虚拟环境用
python -m venv .venv创建,每个项目一套,靠sys.prefix != sys.base_prefix验证生效。 - pip:
install / uninstall / list / show / freeze五件套;版本区间要加引号;不要sudo pip。 - 依赖管理:库用
pyproject.toml声明;应用额外用 requirements/lock 文件保证可复现;开发依赖放[optional-dependencies].dev。 - 典型坑:文件名遮蔽标准库、循环导入、忘记
-e .安装导致 import 失败。
练习题
-
拆模块:把下面这个「所有功能都写在一起」的文件拆成合理的模块结构,并写出每个文件的职责说明。
# 目前全部写在 shop.py 里 import json TAX_RATE = 0.06 DISCOUNT_THRESHOLD = 1000 DISCOUNT_RATE = 0.1 def add_to_cart(cart, name, price, qty): cart.append({"name": name, "price": price, "qty": qty}) return cart def cart_total(cart): return sum(i["price"] * i["qty"] for i in cart) def final_price(cart): t = cart_total(cart) if t >= DISCOUNT_THRESHOLD: t *= (1 - DISCOUNT_RATE) return t * (1 + TAX_RATE) def save_cart(cart, path="cart.json"): with open(path, "w", encoding="utf-8") as f: json.dump(cart, f, ensure_ascii=False, indent=2) def load_cart(path="cart.json"): with open(path, encoding="utf-8") as f: return json.load(f)参考答案拆成包shop/:constants.py(税率与折扣常量)、cart.py(add_to_cart / cart_total / final_price)、storage.py(save_cart / load_cart)、__init__.py(导出add_to_cart, cart_total, final_price并写__all__)。原则:常量、纯计算、IO 三类职责分开;纯计算模块不依赖 IO 模块,便于单元测试。 -
排查遮蔽:某同事新建了一个
random.py,里面定义了choice()。运行后import random; random.randint(1, 10)报AttributeError。请解释原因,并给出两种修复方案。参考答案原因:sys.path[0](脚本所在目录)优先于标准库路径,用户目录里的random.py抢占了标准库random。修复:① 把文件改名为my_random.py或random_utils.py(首选);② 把用户模块放进包中并用相对导入,避免顶层同名。验证方法:python -c "import random; print(random.__file__)"看路径指向了哪里。 -
消除循环导入:现有
a.py顶部from b import B,而b.py顶部from a import A,导入时报错。请用「延迟导入」和「抽公共层」两种思路分别改写。参考答案延迟导入:把b.py对a的导入移到 b 中某个函数的函数体内。抽公共层:新建shared.py放 A、B 共同依赖的数据结构(如class Base、常量),a、b 都只依赖 shared.py。注意:不要在包顶层用import a后再a.B循环引用,那是延迟绑定,只能缓解不能根治。 -
写一个可安装的项目:从零建立一个
booklib/项目,要求:使用src/布局;pyproject.toml中声明 name/version/requires-python/dependencies;提供一个名为booklib的命令行入口;pip install -e .后可以在任意目录执行booklib list。写出完整的目录树与 pyproject.toml 内容。参考答案要点目录树:src/booklib/{__init__.py, cli.py, storage.py}、tests/、pyproject.toml。[project.scripts] booklib = "booklib.cli:main";[build-system] requires=["hatchling"] build-backend="hatchling.build";[tool.hatch.build.targets.wheel] packages=["src/booklib"]。验证:pip install -e .后which booklib应指向.venv/bin/booklib。 -
依赖文件维护:你有一个 Flask 项目,直接依赖
flask==3.0.0与requests,开发时还装了 pytest、ruff。分别写出 (a)pyproject.toml的相关片段,(b)requirements.txt的内容,(c)requirements-dev.txt的内容。参考答案(a)dependencies = ["flask==3.0.0", "requests>=2.31"],[project.optional-dependencies] dev = ["pytest>=8", "ruff>=0.1"];(b)flask==3.0.0与requests>=2.31两行,不要把 werkzeug、jinja2 这些传递依赖写进去;(c)-r requirements.txt加上pytest>=8、ruff>=0.1。若需要完全复现环境,则另外用pip freeze > requirements.lock。 -
入口点守卫:写一个
report.py,里面既有函数build_report(data),又有大量演示代码(循环 print)。要求被import report时不打印任何东西,直接python report.py时执行演示。参考答案把所有演示语句放进if __name__ == "__main__":块内。注意不要用if __name__ != "__main__": return这类花招,因为模块顶层不能return。测试:python -c "import report"应无输出。 -
延迟导入优化:某模块
pdf_tools.py顶层import reportlab(耗时 0.8 秒),但只有to_pdf()用到它。改造它,使import pdf_tools的耗时降到 50ms 以内,并保证to_pdf()仍能正常工作。参考答案把import reportlab移进to_pdf()函数体第一行。可用python -X importtime pdf_tools.py 2>&1 | sort -n -k2 | tail测量各模块导入耗时来验证优化效果。这是 CLI 工具「秒开」的常用技巧。 -
包内相对导入:创建包
proj/,含__init__.py、a.py、b.py,要求a.py用相对导入调用b.py的函数。然后分别尝试python proj/a.py与python -m proj.a,说明各自结果与原因。参考答案python proj/a.py会报ImportError: attempted relative import with no known parent package,因为直接运行时模块没有包上下文;python -m proj.a正常,因为-m会先把proj.a作为子模块导入、建立父包关系,再运行。也可在a.py用绝对导入from proj.b import ...,但那要求运行目录是项目根。 -
综合:设计一个工具包的
__init__.py:写出一个规范的工具包__init__.py,要求包含模块文档字符串、__version__、从两个子模块导入的公开函数、__all__列表,以及一个返回包信息的函数。要求from toolkit import *只会导入公开 API。参考答案示例:
因为定义了"""toolkit:字符串与时间处理工具集。""" from .text_utils import slugify, normalize from .time_utils import now_str, days_between __version__ = "2.1.0" __all__ = ["slugify", "normalize", "now_str", "days_between", "info"] def info() -> str: """返回包的名称与版本。""" return f"toolkit v{__version__}"__all__,from toolkit import *只会带入这 5 个名字,内部的text_utils模块对象不会被导出。