
前几天有位读者在后台问我他的Python脚本已经写到一千多行每次新增一个功能总会连带改崩两个旧功能问我到底该怎么办。我回了一句你不是不会写代码你是还没学会用模块和包来搭自己的代码库。这个问题其实非常普遍。很多朋友学Python时最早接触的是脚本式写法从上到下顺序执行一个文件搞定一切确实爽。但一旦项目变大、需求变多、人员增多就会发现“一个文件写到黑”的代价越来越大一个全局变量被改了两个功能跟着错一个函数改了返回值五个调用方全部要调整更别说想在多个项目里复用代码基本只能靠复制粘贴然后复制出来的版本又逐渐产生分叉。今天这篇我就从模块与包最底层的机制讲起一直聊到怎么设计一个能长期维护的Python代码库结构。先说明白这是一篇偏实战的文章不追求讲全语言规范而是把我在实际项目里踩过的坑、梳理过的逻辑和正在用的目录方案都摊开讲。适合已经能写脚本、但项目一复杂就头大的朋友也适合想把自己积攒的工具函数整理成库的开发者。1. 从“一个文件写到黑”到“模块化”先搞清楚到底卡在哪1.1 脚本与代码库的分水岭在哪里脚本程序的本质是“顺序执行一串操作”它关心的是流程先读数据再处理再写结果。这种代码通常只有一个入口、一个执行上下文所有状态都保存在模块级别的变量里。比如你写一个数据处理脚本可能长这样# data_process.py import json data json.load(open(input.json)) result [] for item in data: if item[active]: result.append(item[value] * 2) print(result) json.dump(result, open(output.json, w))这种写法没有任何问题小任务用它最高效。问题出现在你开始往里面加功能的时候今天加一个登录接口明天加一个数据库操作后天加一个定时任务全堆在同一个文件里。这个时候文件中不再只有一条主流程而是有好几条相互交织的逻辑链。你改数据库连接方式时要担心会不会影响登录鉴权你改登录逻辑时要担心会不会影响数据导出。全局的命名空间里塞了几十个名字你根本分不清哪个名字被哪个逻辑依赖。代码库和脚本的分水岭就在这里脚本解决的是“一件事”代码库解决的是“一类事”。当你的代码开始需要被多处调用、需要被测试、需要被不同项目复用时就不能再把逻辑塞在文件顶部了。你需要的是一种机制把功能拆成相对独立的单元每个单元有明确的边界和对外接口单元内部随便改但只要接口不变外面就不受影响。Python里的模块和包就是为这件事服务的。1.2 模块化收益不是“优雅”而是“能改得动”很多初学者觉得模块化是为了“代码好看”“结构清晰”“显得专业”这些其实是次要的。模块化最核心的收益是降低修改成本你想改一个内部实现的时候不用把整个项目的代码都读一遍也不需要担心全局变量被意外修改。你只需要找到对应模块在它的边界内部动手。举个最直观的例子。你写了一个发送邮件的工具函数一开始用SMTP后来公司换了邮件服务商要改成HTTP API。如果这个函数散落在十个脚本里你得全局搜索替换如果你把它放在一个mailer.py模块里其他模块都用from mailer import send_email来调用那么你只需要改这一个模块的内部实现所有调用方都不用动。这就是模块化带来的“可替换性”。另一个收益是可测试性。脚本的流程跑完就结束了你想测试其中一个环节要么改代码要么手动模拟整个输入。模块则不同你可以单独导入一个模块调用它的函数传入各种边界值验证输出是否符合预期。只要模块没有在导入时顺便执行一堆副作用比如连数据库、发请求测试就能又快又稳。这也是为什么我在后续搭建代码库时一再强调“导入模块时不要执行业务逻辑”。2. 模块的本质导入、搜索路径和pyc缓存究竟发生了什么2.1 import不是一个“读文件”动作而是一个“执行命名”过程很多朋友对import的理解是“把另一个文件的代码搬过来”其实并不准确。import真正的动作是找到目标模块文件在当前进程中执行一遍这个文件然后把执行后产生的模块对象绑定到当前命名空间的某个名字上。这三步缺一不可而且是按顺序发生的。这意味着一个经常被忽略的事实模块里所有顶层代码在导入时都会被真正执行一遍。比如下面这个utils.py# utils.py print(utils module loaded) def add(a, b): return a b当你在另一个文件里写import utils时控制台会打印出utils module loaded。这不是bug而是Python的设计。模块的“主体内容”并不是只有函数和类定义也包括顶层赋值、顶层循环、顶层print。所以写模块时顶层代码里不应该放任何有副作用的语句比如连接数据库、读配置文件、发起网络请求。这些操作应该放进函数里等真正被调用时才执行。另一个容易踩的坑是在模块顶层放一个巨大的初始化过程比如# database.py import mysql.connector conn mysql.connector.connect(hostlocalhost, userroot) cursor conn.cursor()这个模块只要被导入就会立刻连接数据库。如果某天数据库挂了你的整个项目连导入都会失败哪怕你明明只是想要database.py里的一个常量。正确做法是把连接逻辑封装成函数或类# database.py import mysql.connector def get_connection(): return mysql.connector.connect(hostlocalhost, userroot)用到的时候再调用这样模块本身是“惰性”的任何无副作用的使用比如取常量都不会被数据库状态绑架。2.2 搜索路径与sys.path的常见修改方式import第二步是“找到模块文件”。Python解释器按sys.path中的顺序逐个目录查找。sys.path是一个列表通常包含以下几类位置入口脚本所在的目录不是当前工作目录而是运行python xx.py时xx.py所在目录PYTHONPATH环境变量中指定的目录标准库目录site-packages目录即通过pip安装的第三方包所在目录你可以随时在交互式环境里查看import sys for path in sys.path: print(path)理解了sys.path很多导入问题的答案就清晰了。比如你新建了一个项目目录myproject里面有一个helper.py你的入口文件也在myproject下面那么直接import helper能找到因为入口脚本目录被自动加入sys.path。但如果你是先把工作目录cd到其他地方再运行python /path/to/myproject/main.py依然能找到helper.py因为Python加入的是/path/to/myproject而不是当前终端所在目录。如果你的模块放在myproject的上一级或更复杂的位置导入不到时很多人会写import sys sys.path.append(/some/path) import helper这种做法不是不行但我建议你只在临时调试时用。项目代码里到处sys.path.append会让可读性变差而且路径写死了项目一迁移就炸。更好的方案是第三章要讲的包结构以及第四章要讲的用pip install -e安装本地项目。把“项目内依赖”关系交给Python的包管理机制处理远比手动改sys.path可靠。2.3 pyc缓存与__pycache__别一看就删import第三步是执行模块文件但Python在执行前还会做一层优化生成字节码并缓存。你在项目目录里经常看到__pycache__文件夹里面的.pyc文件就是模块的字节码缓存。Python会根据源文件的时间戳和大小判断缓存是否过期。如果你修改了.py文件下次导入时发现缓存不匹配会自动重新编译并覆盖旧缓存。所以正常情况下你根本不用管它。但有些朋友会把__pycache__当成垃圾文件删掉这也没问题代价只是下次导入时重新编译一次略微多花一点点时间。有一个细节值得注意不同Python版本生成的字节码不能混用所以__pycache__里的文件名会带版本标记比如helper.cpython-311.pyc。如果你在同一台机器上装了多个Python版本它们会各自使用独立的缓存文件不会互相覆盖。这点设计得很好。在搭建代码库时建议在.gitignore里把__pycache__/和*.pyc忽略掉避免把这些编译缓存提交进版本库。这不仅是为了仓库干净也是避免团队协作时因为平台差异产生无意义的缓存冲突。3. 包是把模块装进目录init.py、相对导入和命名空间包3.1init.py到底有什么用删了行不行单个.py文件是一个模块多个模块放到同一个目录里再放一个__init__.py这个目录就变成了一个包。包本质上是一个“模块的集合”它还允许你在__init__.py里统一控制对外暴露的接口。__init__.py首先是包的初始化文件。只要这个包被导入Python就会先执行它。所以你可以在这里做一些包级别的准备工作比如统一导入子模块、导出常用名字、设置版本号。例如# mypackage/__init__.py from .core import create_app from .utils import format_date __version__ 0.1.0这样用户只要import mypackage就可以直接使用mypackage.create_app()和mypackage.format_date()不需要再一层层深入子模块。这相当于定义了包的“对外门面”使用体验会好很多。那么问题来了__init__.py能不能删在Python 3.3之后技术上是能删的。没有__init__.py的目录也能被当成“命名空间包”导入我在3.3小节会展开讲。但在绝大多数常规项目里我建议你还是保留这个文件哪怕里面什么都不写。原因有几点第一有__init__.py这个目录就是个普通包行为和预期完全一致不会触发命名空间包的合并规则第二很多工具和静态分析工具默认按普通包处理缺少__init__.py可能引发意外的构建或打包问题第三你需要一个地方放包级元信息和对外导出逻辑空文件总比临时找地方强。3.2 相对导入的“.”和“..”原理与最容易翻车的点包内部模块之间的导入有两种写法。一种是绝对导入直接按顶层包名导入from mypackage.core import create_app另一种是相对导入使用点号表示当前包和父包from .core import create_app from ..utils import helper.代表当前目录当前包..代表上一级目录父包。相对导入在包内部重构时很有用你把mypackage整体改名内部相对导入的模块不需要跟着改。但相对导入有一个非常容易翻车的点它只能在包内使用不能在顶层脚本里直接把包内的某个模块当入口运行。举个例子你的包结构是mypackage/ __init__.py core.py utils.py在core.py里写了from .utils import helper然后你想调试一下core.py直接运行python mypackage/core.py这时候你会看到ImportError: attempted relative import with no known parent package。原因很简单你把core.py当作顶层脚本运行时Python认为这个模块不属于任何包点号无从谈起。解决方式有两种。第一在项目根目录写一个入口脚本比如main.py或run.py在入口脚本里用完整包路径导入core模块比如from mypackage.core import load然后运行python main.py。第二用python -m mypackage.core这种模块方式运行Python会从sys.path中找到包名并执行对应模块此时包结构已知相对导入才能工作。我个人的习惯是绝对导入优先相对导入用在包内部且能确保不会直接以脚本方式运行时。3.3 命名空间包Python 3的意外福利前面提到Python 3.3之后允许没有__init__.py的目录作为包导入这种目录叫“命名空间包”。命名空间包最有趣的特点是同一个包名可以分布在多个不同的目录里导入时会被合并成一个整体。举个实际例子。你有一个插件系统plugins/ a/plugin/__init__.py b/plugin/__init__.py把plugins/a和plugins/b都加入sys.path后你执行import pluginPython会同时从两个位置加载plugin包的内容。对于可插拔架构来说这确实很方便每个插件目录各自独立不需要强行合并成一个包。但命名空间包也有坑如果两个目录下存在同名子模块导入顺序会直接决定谁生效容易造成隐蔽的覆盖。而且很多第三方构建工具对命名空间包的支持不够友好打包时可能漏掉某些目录。我的建议是常规项目还是老老实实用带__init__.py的普通包命名空间包等你确实需要“多个目录拼成一个包”这种场景时再去用它不要图省事拿它当普通包用。4. 搭建个人代码库的目录设计以我的实战项目为例4.1 分层结构可复用的库代码与业务代码必须分开很多初学者搭建项目时习惯把所有.py文件平铺在项目根目录下main.py、utils.py、api.py、models.py全部挤在一起。这种结构在只有三五个文件时还能凑合文件一多就开始混乱。更糟糕的是根目录下的所有模块都处在同一个命名空间里任何模块都能被任何其他模块直接导入依赖关系很快就会变成一团乱麻。我目前比较推荐的是src布局。下面是一个通用模板myproject/ ├── pyproject.toml ├── README.md ├── .gitignore ├── src/ │ └── mypackage/ │ ├── __init__.py │ ├── core.py │ ├── utils.py │ ├── models.py │ └── config.py └── tests/ ├── test_core.py └── test_utils.pysrc布局的核心思想是把可复用的库代码放在src/mypackage里把项目入口、测试、文档、构建配置都放在更外层。为什么要单独加一层src因为它能强制你以“包”的方式看待自己的代码import mypackage.core而不是靠“当前目录下恰好有一个core.py”来导入。这样当你的项目被安装进环境时不会因为其他目录下同名文件而产生冲突。4.2 配置和资源文件应该放在哪一层配置和资源文件是最容易被项目结构设计坑到的地方。很多人习惯在业务代码里调用open(config.json)这隐含了一个假设当前工作目录必须在项目根目录。如果你在项目根目录运行脚本没问题但如果你从其他目录启动程序或者用系统服务方式启动工作目录变了文件就找不到了。正确思路是配置文件应该分为两类。一类是部署时用户可修改的配置比如数据库地址、接口密钥这类应该通过环境变量或外部配置文件传入不要硬编码在代码里更不能依赖“当前工作目录恰好是项目根目录”。另一类是项目自带的静态资源比如模板文件、默认数据、图片资源这类应该放在包内部并且通过基于__file__的路径来定位。在Python 3.7中推荐用pathlib来处理路径。例如from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent DATA_FILE BASE_DIR / data / template.txtPath(__file__).resolve()会拿到当前模块文件的绝对路径再往上翻到项目根目录。只要包结构不发生变化无论你从哪里启动程序这个路径都能正确解析。这里的一个小建议不要在项目里到处写os.getcwd()也不要写相对Path(data/file.txt)这种依赖工作目录的路径全部统一通过模块文件位置定位能避开很多莫名其妙的“文件找不到”问题。4.3 用pip install -e把本地包变成真正的“库”目录设计好了怎么让项目里的其他脚本能够正常import mypackage最简单粗暴的是把src目录加进sys.path但前面我说过不推荐。更规范的做法是把自己的项目以“可编辑模式”安装进当前Python环境。在项目根目录放一个最简pyproject.toml[build-system] requires [setuptools61.0] build-backend setuptools.build_meta [project] name mypackage version 0.1.0 description My personal code library [tool.setuptools.packages.find] where [src]然后在项目根目录执行pip install -e .加了-e就是可编辑安装。它的意思是把当前项目注册进环境但源码仍然用你磁盘上的原始文件。这样你修改src/mypackage里的代码其他脚本立即就能看到新内容不需要每次改动后重新安装。这和把代码复制到site-packages的普通安装方式完全不同非常适合本地开发。安装之后你在任何目录下打开Python解释器直接import mypackage都能成功不再受工作目录限制。这就算真正把代码库“立”起来了结构上是包使用上像第三方库修改时又保留实时生效的便利。5. 避坑实录循环导入、路径硬编码、模块被重复加载5.1 循环导入的完整排查链路一个真实案例循环导入是包逐渐变大之后最经典的坑。两个模块互相导入或者通过第三个模块间接形成环都会出问题。我拿一个简化过的真实案例来说明。假设有这样一个包mypackage/ __init__.py user.py order.pyuser.py里定义了一个函数需要用到order.py中的一个工具函数# user.py from mypackage.order import build_order_id class User: def __init__(self, name): self.name nameorder.py里也定义了一个函数需要用到user.py中的某个模型# order.py from mypackage.user import User def create_order(user: User): return build_order_id()表面上看两个模块各自导入对方能不能跑取决于执行顺序。如果你先导入user.py它会去导入order.pyorder.py又回头导入user。但这时user模块还在执行过程中还没有完成定义所以order.py里的from mypackage.user import User就会抛出ImportError: cannot import name User from partially initialized module。排查这种问题我自己的习惯是三步走。第一步看报错信息里提到的“partially initialized module”。找到报告的最内层错误通常会明确指出是哪个模块在哪个导入关系下断掉的。第二步检查入口导入顺序。是main.py先导入了谁很多时候循环导入不是必然崩溃而是取决于先执行哪个模块。把两个模块的导入顺序梳理出来你就能看到环在哪里。第三步用延迟导入或者结构调整解除循环。延迟导入是把其中一个模块内的导入语句从文件顶部挪进函数内部比如# order.py def create_order(user): from mypackage.user import User return build_order_id()这样只有函数被调用时才会执行导入而那时两个模块都已经完整加载。但延迟导入不是长久之计最好的办法是提取共同依赖。我把两个模块都需要的工具函数或者基础模型放到一个新模块common.py让user和order都去导入common环自然就断了。我在实际项目里还遇到过一种更隐蔽的情况循环导入不是直接报错而是一方拿到的是“None”。因为其中一方才刚开始定义类另一方就去取这个名字取到了尚未绑定完成的None。这种问题更难发现但排查思路一样先梳理导入顺序再看是否有顶层互相导入。5.2 硬编码路径为什么在打包后必炸前面讲配置和资源文件时提到了路径问题这里我再展开说坑。很多项目开发时一切正常一旦打包成可执行文件或安装成系统服务就开始报“FileNotFoundError: [Errno 2] No such file or directory”原因几乎都是硬编码路径。举例来说你写了with open(data/config.json) as f: config json.load(f)这句代码能不能跑通完全取决于程序运行时的“当前工作目录”是不是data的上一级。开发时你从项目根目录启动当然没问题。但如果你用systemd服务、定时任务、Docker容器启动程序工作目录很可能是/或者别的位置这个相对路径就失效了。正确做法是让路径锚定在模块文件的真实位置。注意Path(__file__).parent得到的是模块文件所在目录不会跟随“当前工作目录”变化。但如果你用的是src布局__file__指向src/mypackage/config.py资源文件如果放在项目根目录你得往上翻两级才能找到。所以我通常会用一个统一定义路径的地方# config.py from pathlib import Path PACKAGE_ROOT Path(__file__).resolve().parent PROJECT_ROOT PACKAGE_ROOT.parent.parent这样其他地方都从config.py导入这些路径常量不会散落一堆Path(__file__).parents[1]的魔法数字。5.3 reload、缓存与调试时的状态残留调试时还有一类坑来自“模块状态残留”。最常见的是在Jupyter Notebook里反复运行单元格每次运行都会重新导入模块但Python为了保证效率会把已经导入的模块放到sys.modules缓存中不会真正重新执行代码。于是你改了模块源码再跑一次import mypackage看到的还是旧内容。这时有人会用importlib.reload强制重新加载import importlib import mypackage importlib.reload(mypackage)但reload也有很多限制它不会重新执行依赖mypackage的其他模块from mypackage import some_name已经绑定到旧对象reload后这个变量还是指向旧实现。所以我建议开发时还是用pip install -e配合解释器重启来调试尽量少依赖reload。Notebook里也要养成“改完模块后重启内核”的习惯否则你会被缓存搞到怀疑人生。另一个和重复加载相关的问题是同一个模块被两份不同的路径导入导致出现两个类对象。比如src/mypackage既在sys.path里你又手动sys.path.append了src那mypackage.user可能加载了两份。此时你用isinstance(obj, User)判断会出现奇怪的结果明明对象是这个地方创建的类型检查却失败。排查方法也很直接在模块里打印__file__看实际加载物理路径如果同一个模块显示了两个不同的绝对路径说明被重复加载了。解决方案是统一入口、统一安装方式不要手动往sys.path里乱加路径。6. 让代码库可以交付依赖管理、入口点与发布前检查6.1 requirements.txt、pyproject.toml怎么选搭建代码库不只是把.py文件组织好还要解决依赖问题。很多项目只有一份requirements.txt这是把“应用项目”和“库项目”混为一谈了。requirements.txt适合“应用项目”它需要锁定精确版本保证部署环境可复现。但如果你的项目会被别人当作库安装就不适合在requirements.txt里写死版本因为那会强制覆盖使用者环境里的第三方包版本引起冲突。库项目应该把依赖写在pyproject.toml的dependencies字段里并且尽量用宽松的范围比如[project] dependencies [ requests2.20,3.0, pydantic1.10,3.0, ]这样使用你库的人在安装时会由pip在当前环境中解析出兼容版本。如果非要锁定可以用requirements-dev.txt来锁定开发环境的完整依赖但它只服务于开发阶段。另外不要随手把pip freeze的全部输出重定向到requirements.txt。pip freeze会导出当前环境的所有包包括很多与项目无关的依赖。正确做法是用pip freeze作为参考然后手动整理出项目真正直接依赖的包列表。现在也可以用pip-tools或uv这类工具根据pyproject.toml生成锁文件团队协作时更可控。6.2 console_scripts入口点把模块变成命令行工具代码库不只是一堆可以被import的模块也应该有“开箱即用”的入口。最推荐的入口方式是在pyproject.toml里配置[project.scripts]把包里的某个函数直接映射成系统级的命令行命令。比如你写了一个模块mypackage/cli.py里面有def main(): print(Hello from my package)在pyproject.toml里加上[project.scripts] mypkg mypackage.cli:main然后重新执行pip install -e .安装完成后在终端里输入mypkg就可以直接运行main()函数。pip会自动生成一个可执行脚本放在当前Python环境的bin或Scripts目录下脚本内部会导入mypackage.cli并调用main()。这个过程对用户完全透明对库的维护者来说也比让用户记“python -m mypackage.cli”更友好。如果想支持命令行参数可以用argparse或者更省事的click、typer等第三方库。只要入口函数接收参数并正确返回就能接进console_scripts。这种方式特别适合“把自己私有的小工具集整理成一个命令行工具箱”既方便自己用也方便分享给同事。6.3 发布前自检清单等到代码库结构、依赖、入口都准备得差不多如果在本地开发没问题但想让它长期稳定我建议每次发布或交接前过一遍自检清单都是我踩过坑后总结出来的在干净虚拟环境里安装验证。新建一个空的venv执行pip install -e .再执行一个最简单的示例脚本确认基本功能可用。检查导入时是否有副作用。导入包时不应该有print输出不应该自动连接数据库或发网络请求。如果导入时被卡住多半是顶层代码执行了耗时或阻塞操作。检查有无硬编码路径。全局搜索open(、Path(和os.getcwd()逐一确认路径是否都是基于__file__或环境变量。检查相对导入是否只在包内使用。确认有没有哪个模块本身会被当作入口脚本运行如果会就得把相对导入改成绝对导入。运行完整测试。python -m pytest确保核心功能都有测试覆盖至少主流程不能崩。检查pyproject.toml的包发现配置。执行一次构建看dist里生成的文件是否包含所有需要的子模块特别是嵌套目录下的模块。看一眼README是否和实际行为一致。很多项目代码更新了文档还停留在半年前接收者很容易被误导。我自己的习惯是每次对自己说“这个版本可以给别人用了”之前都跑到一个全新目录里建venv、安装、跑一遍顺手还能发现自己遗漏的依赖。这个习惯让我少挨了很多骂。最后再补一个个人感受把代码当成正式项目去组织前几次会觉得很慢连一个工具函数都要想清楚放哪个模块、导出哪些接口。但是一旦熬过了头几回后面再写新功能时思路会非常顺。因为你开始习惯先画边界、再写实现代码库的每一层都在帮你兜底。Python的模块和包机制本身不复杂真正复杂的是你愿不愿意把“能跑”变成“能长期维护”。希望这篇文章能帮你迈过这一步。