K 的一隅

Python Python 语言核心

import 路径与包结构:模块化怎么组织代码

模块与包的区别、import 几种形式、__init__.py 的作用,以及 sys.path 决定解释器从哪找 .py 文件。

8 分钟阅读 更新于 2026-07-28
本文目录

项目从单个 main.py 长成一摞文件时,最先撞上的往往不是语法,而是 import 报错ModuleNotFoundError: No module named 'utils'。解释器找模块有固定规则——文件名、目录层级、sys.path 里登记过的搜索路径。弄懂「模块 vs 包」和 import 的几种写法,比死记目录模板有用。

可以把 import 想成一张地图:每个 .py 是一个地点,包是带入口的文件夹,sys.path 是「允许搜索的街道列表」。名字解析失败,不是 Python 坏了,而是当前进程里这张地图没把你的文件画进去。

模块:一个 .py 文件就是一个模块

utils.py 被 import 时,模块名通常是 utils(不含 .py)。模块顶层定义的函数、类、变量,成为该模块命名空间里的名字:

python
# utils.py
def slugify(text: str) -> str:
    return text.strip().lower().replace(" ", "-")


MAX_LEN: int = 80
python
# main.py
import utils

print(utils.slugify("Hello World"))  # hello-world
print(utils.MAX_LEN)

模块在进程内只初始化一次:多个文件 import utils,共享同一个模块对象(单例缓存放在 sys.modules 里)。因此模块顶层的副作用代码(读配置、连数据库)会在第一次 import 时执行,后续 import 直接复用缓存。

import 的几种常见形式

python
import json                          # 绑定整个模块对象
from pathlib import Path             # 只引入 Path 到当前命名空间
from collections import deque as dq  # 别名,避免与本地名冲突
import utils.format as fmt             # 子模块,要求 utils 是包

from package import * 会把 __all__ 列出的名字导入当前命名空间,可读性差、易冲突,库代码里少见,应用脚本也不推荐。

相对 import 只在包内部使用,以 . 表示当前包、以 .. 表示上级包:

python
# myapp/services/report.py
from ..models.user import User      # 上一级包的 models.user
from .export import render_csv      # 同包下的 export 模块

脚本直接 python report.py 时,相对 import 常失败——因为该文件不被视为包的一部分。包内模块应通过 python -m myapp.services.report 或从包外 import 来启动。

包:带 init 的目录

目录要成为 ,通常放 __init__.py(3.3+ 有隐式 namespace package,但显式 __init__.py 仍是项目里最清晰的做法)。包名对应目录名:

myapp/
  __init__.py
  models/
    __init__.py
    user.py
  services/
    __init__.py
    auth.py
  main.py
python
# myapp/models/user.py
class User:
    def __init__(self, name: str) -> None:
        self.name = name
python
# myapp/main.py
from myapp.models.user import User

u = User("Ada")

__init__.py 在包被第一次 import 时执行,常用来聚合子模块的公开 API:

python
# myapp/models/__init__.py
from myapp.models.user import User

__all__ = ["User"]

这样外部可以 from myapp.models import User,而不必记深层路径。__all__ 还约束 from myapp.models import * 导出哪些名字——日常仍建议显式 import。

sys.path:解释器去哪找模块

sys.path 是一个路径列表。import 时,解释器按顺序在这些目录里查找包或模块:

python
import sys

for entry in sys.path[:5]:
    print(entry)

默认包含:stdlib 的 zip/目录、site-packages(第三方与已安装包)、以及启动方式决定的「首项」——用 python path/to/main.py 时,常把 main.py 所在目录加进去;用 python -m myapp.main 时,常把当前工作目录或项目根加进去。同一份代码,换启动命令,path 首项可能变,这就是「IDE 能跑、命令行报错」的常见根因。

若源码在 src/myapp/ 布局,应让 src 或安装后的包根在 path 里:

bash
# 开发期 editable 安装,稳定 import myapp
pip install -e .

或临时设置环境变量 PYTHONPATH=src(团队项目更推荐可安装包,少依赖手工环境变量)。

循环 import 与延迟 import

A.py import B,B 又 import A,可能在模块体尚未执行完时访问未定义的名字。缓解手段:

  • 把共享类型、常量抽到第三模块 common.py
  • 把 import 挪进函数体内(延迟到调用时才加载);
  • 重构层级,让依赖单向流动(models 不 import views)。
python
def build_report() -> str:
    from myapp.services.export import render_csv  # 延迟 import
    return render_csv([])

组织习惯

职责拆文件:配置、模型、I/O、CLI 各成模块;文件名小写加下划线,与模块名一致。包内 import 优先绝对路径from myapp.models import User),相对 import 留给包内部子模块互引。

读陌生仓库时,从入口(main.py__main__.py、CLI 命令)沿 import 链往外画一张小地图:谁依赖谁、哪一层不该反向依赖上层。比从头扫 every .py 快得多,也更容易发现「本该是包却散成一堆平铺脚本」的结构债。

if __name__ == "__main__" 与可执行模块

脚本直接运行时,__name__"__main__";被 import 时为模块名。常用模式是把「仅命令行执行」的逻辑放进 guard:

python
# myapp/main.py
def main() -> None:
    print("run app")


if __name__ == "__main__":
    main()

包目录下加 __main__.py 后,可以 python -m myapp 启动,而不必记 main.py 的路径——此时 myapp 作为包被运行,__main__.py 充当入口。

命名空间与 import * 的代价

每个模块有自己的全局命名空间 dict;import 把绑定放进当前模块的 dict。from foo import bar 只在当前模块增加名字 bar,不自动引入 foo 的其他属性——除非你再 import foo 或继续 from import。

import * 会批量复制名字,破坏「名字从哪来」的可追踪性,也易与本地变量冲突。库作者用 __all__ 声明公开面;应用代码仍应 from myapp.models import User 这种显式写法。

常见 import 报错对照

报错 / 现象常见原因
ModuleNotFoundError目录不在 sys.path;拼写错;未安装包
ImportError: attempted relative import with no known parent package直接运行包内文件,未 -m
循环 import 时属性为 None 或缺失模块体执行顺序问题,需拆依赖或延迟 import
IDE 能跳转、终端不能跑两套 python / PYTHONPATH 不一致

小结

模块是一个 .py 文件及其命名空间;是带 __init__.py 的目录树。import 把模块或名字绑进当前命名空间;sys.path 决定搜索起点。工程上优先绝对 import、可安装布局、venv 内一致的启动方式(python -m ...)。地图画清楚,模块化才是减复杂度,而不是多出一层找不到的文件。

src 布局与测试里的 import

常见 src layout 把包放在 src/myapp/,测试在 tests/,根目录放配置。好处是:未安装时不容易「误 import 到源码旁一份同名模块」;安装后 import myapp 与发布行为一致。

project/
  pyproject.toml
  src/
    myapp/
      __init__.py
      core.py
  tests/
    test_core.py

测试文件里通常 from myapp.core import foo,前提是开发机已 pip install -e .。纯靠把 src 加进 PYTHONPATH 也能跑 pytest,但和新同事 onboarding 文档不一致时,又会出现「我这边能 import、你那边不能」的分叉。

pytest 发现测试模块时,当前目录与已安装包都会在 sys.path 里占一席。若根目录恰好有与标准库或第三方同名的 .py(比如手滑写了 json.py),会 shadow 真模块——报错往往离奇,排查时要记得「本地文件名也是 import 地图的一部分」。

拆分模块的尺度

单文件几百行尚可;上千行且职责混杂就该拆。经验信号:同一文件里既有 HTTP 路由又有 SQL 又有 PDF 导出——读者 import 时不知道「该从哪拿哪个概念」。拆成 models/services/api/ 后,每层 import 方向尽量单向:api → services → models,models 不反向 import api。

过碎也有成本:每个函数一个文件,import 链比业务还长。以「读一个功能要打开几个文件、名字是否自解释」为标尺,比数行数更有用。模块化服务于可读与可测,不是文件系统炫技。

重新导出与「公开面」

大包常在顶层 __init__.py 做 re-export,对外暴露稳定 API,内部模块随意重构:

python
# myapp/__init__.py
from myapp.models.user import User
from myapp.services.auth import login

__all__ = ["User", "login"]

外部写 from myapp import User,不必知道 User 实际定义在 models/user.py。这是有意设计的抽象边界:文档与示例只提公开名,子模块路径可以随重构改变。维护者改内部路径时,只要 __init__.py 的 re-export 不变,下游 import 就不碎。

与之相对,深层 importfrom myapp.models.user import User)在内部重构时更容易大面积改 call site。库作者倾向 re-export;应用内部模块之间可以用深层 import 换更明确的依赖图——团队自定规范即可,关键是全项目一致。

动手拆模块前,可以先画一张依赖箭头图:谁 import 谁、有没有环。环不一定立刻爆炸,但往往是循环 import 与测试难写的先兆。把「数据定义」放在内层、「I/O 与框架胶水」放在外层,是多数 Web 与 CLI 项目能直接套用的分层直觉。

python -m package.module 与直接 python package/module.py 的差别,本质是「这个文件在不在包的上下文里」。文档示例、CI 脚本、可安装 CLI 入口应统一一种启动方式,并在 README 写死。

模块化没有唯一标准目录树,但有可验证规则:import 能解析依赖尽量单向公开面稳定。遇到 ModuleNotFoundError 先查 sys.path 与启动命令,而不是盲目改包名。

__init__.py 可以是空文件,仅用来标记「这是包」;也可以承担聚合导出与包级初始化逻辑。无论哪种,模块 的区分在于目录层级与 import 路径,不在于文件行数多少。

sys.path 是理解 import 失败的钥匙:首项随启动方式变化,site-packages 总在列表里。把 path 打印出来对照目录结构,比反复改名更有效。团队协作时在 README 写明推荐的 python -m ... 启动命令,可减少「我这边能 import」类问题。

import 解析的是字符串路径,不是文件系统绝对路径——同名模块在不同目录只会加载先匹配到的那一个。import 语句本身不耗时,但错误的模块边界会在每次改功能时反复收税——这张地图值得在重构前花十分钟画好。