本章

进阶篇 / 第 9 章

第 9 章 · 模块、包与依赖管理

本章导读:前面八章我们把语法、数据结构、函数与面向对象都过了一遍,但从第 9 章开始,你要写的将不再是「一个 .py 文件」,而是一个真正的项目。本章解决三个问题:怎么把代码拆成可复用的模块与包(import 机制到底做了什么)、怎么用虚拟环境隔离依赖(避免 A 项目和 B 项目打架)、以及怎么把依赖可靠地记录与分发(requirements.txt 与 pyproject.toml)。前置知识:函数、类的基本用法,会在命令行敲几行命令。学完本章,你能独立搭出一个规范的 Python 项目骨架,把代码从「一堆脚本」升级为「可安装、可协作、可部署的工程」。

模块:把大文件拆成小块

在前八章里,我们的代码可能都写在同一个文件里。一旦超过 800 行,这个文件就会变得难以阅读、难以复用、难以测试。Python 提供了模块(module)来解决这个问题:一个以 .py 结尾的文件就是一个模块。

模块的价值可以概括为三点:

  1. 拆分:把上千行代码按职责拆成若干文件,每个文件专注于一件事。
  2. 复用:写好的模块可以被其他脚本、其他项目直接 import,不用复制粘贴。
  3. 隔离:每个模块有自己的全局命名空间(名字作用域),避免变量互相污染。
要点Python 没有内置的「函数」和「类」之外的语言结构来组织大型代码——模块就是它唯一的组织单位。所谓「包」不过是模块的目录化组织,「依赖管理」不过是模块的下载与版本控制工具。三个概念其实是同一件事的三个层次。

第一个模块: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 的内容大致是:

  1. 运行脚本时,脚本所在目录(注意:不是你的 shell 当前工作目录!)。
  2. 环境变量 PYTHONPATH 中列出的目录。
  3. 标准库目录(lib/python3.12)。
  4. 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] 模块正在被导入...")   # 这一行在每次首次导入时都会打印
要点导入时执行顶层代码,等价于「把整个模块从头到尾跑一遍,然后把 resulting 的命名空间交给你」。模块只会被执行一次,之后都从缓存拿——这引出下一节。

阶段四:缓存(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('导入完成')"
导入完成            # 注意:演示没有执行
技巧这一条几乎出现在每一个正经的 Python 项目里,包括标准库(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对应工具的锁定自动✅ 必须
要点库项目(给别人 import 的)只需 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+ 起的标准做法。

常见坑与最佳实践清单

导入相关

  1. 遮蔽标准库:不要创建 random.py、logging.py、json.py、types.py、email.py。
  2. 循环导入:A 导入 B、B 又导入 A。症状是 ImportError: cannot import name 'X' from partially initialized module。解法:把公共部分抽到第三个模块,或改用延迟导入(函数内部 import)。
  3. 忘记 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 写明:如何建虚拟环境、如何装依赖、如何运行、如何跑测试。
要点很多 IDE(PyCharm、VS Code)在新建项目时会自动帮你建 .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 失败。

练习题

  1. 拆模块:把下面这个「所有功能都写在一起」的文件拆成合理的模块结构,并写出每个文件的职责说明。

    # 目前全部写在 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 模块,便于单元测试。
  2. 排查遮蔽:某同事新建了一个 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__)" 看路径指向了哪里。
  3. 消除循环导入:现有 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 循环引用,那是延迟绑定,只能缓解不能根治。
  4. 写一个可安装的项目:从零建立一个 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。
  5. 依赖文件维护:你有一个 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。
  6. 入口点守卫:写一个 report.py,里面既有函数 build_report(data),又有大量演示代码(循环 print)。要求被 import report 时不打印任何东西,直接 python report.py 时执行演示。

    参考答案把所有演示语句放进 if __name__ == "__main__": 块内。注意不要用 if __name__ != "__main__": return 这类花招,因为模块顶层不能 return。测试:python -c "import report" 应无输出。
  7. 延迟导入优化:某模块 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 工具「秒开」的常用技巧。
  8. 包内相对导入:创建包 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 ...,但那要求运行目录是项目根。
  9. 综合:设计一个工具包的 __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 模块对象不会被导出。
Python 完全教程 · 第 9 章