Python项目结构实战:从单文件脚本到模块化工程组织

📅 发布时间:2026/10/13 11:39:46
Python项目结构实战:从单文件脚本到模块化工程组织
很多写 Python 的朋友都经历过这种阶段脚本从几十行慢慢长到了上千行功能都在跑但每次加新需求你都得先花半小时回忆“那一段逻辑到底写在了哪里”。Python 项目结构这件事表面看是“文件往哪放”的仪式感问题实际上决定了一个项目还能不能继续往下做、还能不能交给别人维护。这篇文章不聊空泛的工程理念只讲我在真实项目里反复验证过的组织套路目录怎么拆、配置怎么写、导入和路径有哪些坑、最常见的踩坑现场怎么救回来。如果你正打算从一个单文件脚本走向一个像样的项目或者已经被一堆杂乱的模块搞得头疼这篇文章应该能给你一套可以直接抄走的方法。1. 为什么项目结构决定代码的寿命1.1 代码膨胀是分水岭我先说一个判断标准当你的代码量超过大约 1500 行或者业务逻辑开始牵扯到多个不同方向比如一边要抓取数据、一边要清洗数据、一边要落库单文件脚本的维护成本就会指数级上升。最典型的症状是函数定义顺序不小心变成“隐藏依赖”前面的函数用的是后面函数才创建的全局变量改了一处逻辑旁边看似无关的函数跟着报错。我最早写项目的时候就吃过这个亏。当时把采集、去重、入库、告警全放在一个run.py里代码只有 1200 行看起来“还挺清晰”。但后来业务加了重试机制和新字段映射我需要反复在文件里搜变量名改完一个分支又担心影响另一个分支。后来花了半天时间拆成了多个模块从那天起每次改需求花的时间明显缩短。拆代码这件事真正的回报不是“干净”而是降低你的心智负担。1.2 三个结构原则高内聚、低耦合、单向依赖组织 Python 项目不需要背诵任何高深理论你只需要把握三个原则。高内聚把相关的东西放在一起。用户相关的模型、存储、校验逻辑放在一个业务域里采集相关的逻辑放在另一个业务域里。不要按“工具函数”和“业务函数”这种粗粒度分那只会产生一个装了所有东西的大抽屉。低耦合模块之间不要直接读到对方的全局状态不要互相 new 对方的内部对象尽量通过参数、返回值、明确的调用接口来协作。这样当你改一边时另一边不用跟着地震。单向依赖依赖方向应该清楚。入口 → 业务逻辑 → 数据访问 → 基础设施层层向下不要反向依赖。一旦出现了“两个模块互相 import”你就踩到了循环导入的雷区我之后会专门讲这个问题。这三个原则不需要一上来就做到完美但它们是你在结构上做任何决策时的判断尺子。注意有些团队习惯把通用函数全部丢进utils.py这其实并不是高内聚它只是一个杂物箱。更健康的做法是让每个工具文件有明确的职责timeutil.py管时间计算textutil.py管字符串处理fileutil.py管文件读写。调用方看到文件名就能猜到函数在哪而不是打开一个 800 行的utils.py来回翻。2. 项目布局的常见方案与选型思路2.1 两种主流布局扁平布局 vs src布局组织 Python 项目你先会遇到第一个分叉代码到底是直接放在仓库根目录下还是统一放在src/下面。扁平布局长这样my_project/ ├── my_project/ │ ├── __init__.py │ ├── core.py │ └── cli.py ├── tests/ │ ├── test_core.py │ └── conftest.py ├── requirements.txt └── pyproject.tomlsrc 布局长这样my_project/ ├── src/ │ └── my_project/ │ ├── __init__.py │ ├── core.py │ └── cli.py ├── tests/ │ ├── test_core.py │ └── conftest.py ├── requirements.txt └── pyproject.toml差异看着不大但影响很大。扁平布局在开发阶段最顺手因为pip install -e .之后项目包直接就在当前环境里pytest 也容易找到导入路径所以小工具、内部脚本、快速原型我一般会用这种。src 布局更严格核心代码被隔离在src/下项目根目录不会因为各种额外文件被污染也不会出现“根目录的test.py意外覆盖了标准库模块”这种问题。更重要的是它强制你把src/my_project当成一个真正的包来管理而不是“把一堆文件堆在根目录里”。我建议所有准备发布成库、或者有清晰对外 API 的项目使用 src 布局。2.2 目录职责怎么分不管用哪种布局一个成熟项目的目录职责大致是固定的。我按自己常用的标准列一下目录/文件职责常见误区项目包目录my_project/或src/my_project/核心代码所有业务逻辑都在这里把脚本、日志、临时测试也放进包里tests/自动化测试代码测试代码和被测包混在一起docs/文档、设计说明、使用指南没有 docs靠 README 硬撑scripts/运维类可执行脚本、数据迁移脚本和核心包代码混在一起pyproject.toml项目元数据、构建配置、工具配置所有配置都丢进 setup.py 和 requirements.txtREADME.md项目说明、安装方式、快速上手写成空泛的“项目简介”.gitignoreGit 忽略规则忘掉.venv、__pycache__导致仓库混乱.venv/虚拟环境目录没有虚拟环境全局依赖互相污染我特别想说tests/的边界问题。很多新手会把测试文件建在项目包内部比如my_project/test_xxx.py这样虽然导入方便但你发布包的时候测试文件会一起被打包发布污染发行产物。正确做法是测试目录独立在项目根目录下。2.3 不同项目类型怎么选布局我总结了一个选型规则大家可以按项目定位来对号入座一次性脚本、数据处理脚本、个人小工具一个脚本文件就够如果你后面发现脚本超过 500 行再拆成同目录下的几个模块也不迟。内部服务、后端应用、需要长期维护的项目建议用扁平布局或分层布局项目包放根目录配合pyproject.toml安装编辑模式开发和测试都很顺。准备发布到 PyPI 的库、对 API 有严格要求的基础组件用 src 布局保证发布内容干净测试时导入的是“安装后的包”而不是“开发目录里的零散文件”。这几种选型没有绝对的对错但有适用边界。我见过有人给一个发布到内部的公共库用了扁平布局结果因为项目根目录下还有其他业务目录导致包导入时把根目录也纳入了搜索路径出现奇怪的ModuleNotFoundError改成 src 布局后问题消失。你越早明确这个项目会是“库”还是“应用”越不容易后期大改。3. 关键配置与文件把边界写清楚3.1 pyproject.toml 是配置中心很多老项目还在用setup.py加requirements.txt各管各的但新项目已经不需要这么分散了。pyproject.toml是 Python 生态官方的项目配置入口它能同时描述构建系统、项目元数据、依赖、命令行入口、甚至工具配置比如 pytest 和 ruff。一个最小的pyproject.toml长这样[build-system] requires [setuptools68] build-backend setuptools.build_meta [project] name sim-project version 0.1.0 description 一个用于演示项目结构的模拟项目 requires-python 3.10 dependencies [ requests2.31, ] [project.optional-dependencies] dev [ pytest7.4, ruff0.1, ] [project.scripts] sim-project sim_project.cli:main这里最关键的一行是[project.scripts]它把包里的cli.py中的main函数注册成命令行命令sim-project。以后你只要pip install -e .在终端里直接敲sim-project就能运行入口。这比“用python main.py”更专业也比每次都要找路径更省心。我自己现在的习惯是依赖要用pip-compile或uv生成锁文件但项目元数据和入口配置只写在pyproject.toml里requirements.txt只保留给部署环境做快速安装用的固定版本列表不再作为主要配置源。注意[project.scripts]的 key 是你在终端里敲的命令名value 的格式是包名:函数路径。它要求那个函数必须可以被“直接 import”如果cli.py里的 main 依赖了被嵌套在if __name__ __main__里的内容注册成的命令就会报错。所以在cli.py里要把真正的入口函数写成一个顶层函数而不是写在 if 块里。3.2init.py 的坑与技巧__init__.py是包的标志文件它的存在与否会影响 Python 识别包的方式。Python 3 其实支持 namespace package没有__init__.py也能导入但显式加上它有三个好处第一版本兼容性更好第二可以在导入父包时预置一些公共 API第三防止同名包被意外合并。不过__init__.py也是最容易写过头的地方。常见的恶习是在里面写from .module import *或者把大量子模块的导入全塞进来导致你只要导入包整个项目的模块链全被加载一遍启动速度变慢还容易引发循环导入。我更推荐的做法是把它当成“对外门面”。比如# src/sim_project/__init__.py from .cli import main from .services.order_service import OrderService __all__ [main, OrderService]这样外部使用者只暴露两个核心接口内部其他模块随便拆。但一定要注意__init__.py里 import 的模块不应该再反向 import 它否则就会出现极其隐蔽的循环导入。3.3 配置、路径和入口点项目里最容易出问题的另一个地方是路径。我见过太多人写代码时习惯用相对系统当前目录的路径比如open(config.yml)一旦你用python -m从其他目录启动项目路径就找不到了。正确思路是所有路径都基于项目包确定的位置来计算。现代做法是使用标准库pathlibfrom pathlib import Path ROOT_DIR Path(__file__).resolve().parent.parent.parent DATA_DIR ROOT_DIR / data CONFIG_PATH ROOT_DIR / config / settings.yaml这里Path(__file__)拿到的是当前文件路径.resolve()去掉符号链接和相对细节再向上找到项目根。这样不管你在终端哪个目录下运行路径永远是稳定不变的。配置文件本身也不要硬编码在代码里。我习惯在包里放一个settings.py专门负责从环境变量或.env文件读取配置import os class Settings: app_name: str os.getenv(APP_NAME, sim-project) log_level: str os.getenv(LOG_LEVEL, INFO) database_url: str os.getenv(DATABASE_URL, sqlite:///./dev.db) settings Settings()这样做的好处是代码里不出现 API key、数据库地址这些敏感信息环境变量一旦注入就是运行时配置不用改代码就能切换环境。3.4 入口点设计main.py 与main.py很多项目喜欢在根目录放一个main.py这在最简单场景下能跑但不是一个好习惯。根目录放main.py会让你的入口模块和项目包之间产生两种完全不同的导入视角直接python main.py时根目录在 sys.path 里而python -m my_project.cli时项目包的父目录在 sys.path 里同一套代码在不同启动方式下行为可能不一样。更稳定的做法是把入口放到包内部提供一个__main__.py或者cli.py然后用python -m启动。src/sim_project/__main__.py里的内容可以非常简洁from .cli import main if __name__ __main__: main()之后统一用python -m sim_project启动项目。这个方式的额外好处是当项目被安装到环境里python -m依旧能正确找到入口不会因为工作目录变化而失效。加上我在 3.1 里提到的[project.scripts]注册方式终端命令和模块启动两者可以并存一套代码多处入口非常省事。4. 导入边界最容易翻车的地方4.1 绝对导入与相对导入的使用边界Python 代码组织最绕不开的就是 import 语法。from package.module import thing是绝对导入from .module import thing是相对导入。两者都合理但使用场景完全不同。在包内部模块之间应该优先用相对导入。因为相对导入是“以当前包为基准”的以后如果要把整个包改名、迁移到别的命名空间底下相对导入的代码基本不用动。比如在services/order_service.py里写from ..models.user import User from ..repositories.order_repo import OrderRepo这里..是往上层找包。这种写法清晰地表达了模块之间的层次关系。绝对导入则适合访问第三方库和外部包。比如import requests、from fastapi import FastAPI。另外当你需要从项目的顶层包导入但这段代码不在包内比如脚本目录下的工具脚本时绝对导入也是更安全的。注意相对导入有个著名雷区。当你把某个模块当脚本直接运行比如python src/sim_project/services/order_service.py它里面写的from ..models.user import User会直接报错attempted relative import with no known parent package。原因很简单作为脚本运行时这个文件是__main__它没有“父包”可依附。解决办法是统一用python -m启动项目或者在pyproject.toml里注册入口。4.2 循环导入的根因和解法循环导入是新手最容易撞上的墙错误信息一般长这样ImportError: cannot import name X from module_a它本质上是一个初始化顺序问题。Python 导入模块时会把模块加载结果放进sys.modules缓存如果模块 A 导入了模块 B而 B 又去导入 A那么当 B 导入 A 时A 可能还没执行完所以它引用到的某个名字还不存在。我举个例子# module_a.py from module_b import helper value_a helper()# module_b.py from module_a import value_a def helper(): return value_a 1这个循环看起来很蠢但真实项目里它藏在业务关系里一个服务类引用了一个模型模型又引用了服务的枚举。解法有三种按优先级排序第一种是重构把互相依赖的部分抽到公共下层模块里。真正的高内聚低耦合说得就是这种需要抽公共依赖的情况。第二种是延迟导入把 import 写进函数内部而非模块顶层。这样只有当函数真正被调用时才会去加载依赖模块模块初始化的顺序问题就被绕开了。第三种是改成接口注入。A 不直接 import B而是由外部把 B 的实例传进来模块之间只依赖抽象接口。我最推荐第一种因为延迟导入虽然能用但它只是把问题延后项目代码里到处藏 import 反而增加了阅读负担。4.3 别把 utils 变成杂物箱我前面提过utils.py问题这里再多说几句。很多项目的结构崩溃不是从“没有目录分层”开始的而是从一个膨胀的utils.py开始的。当你把format_time、save_to_excel、send_email、json_load全部都放进同一个utils.py并且所有业务模块都from utils import *那么utils.py就和全局变量没有任何区别你改任何一个函数都可能影响到几十个调用方。正确做法是按功能域再细分一层并且不要让业务层直接依赖一个“万能工具包”而是按需依赖具体的小模块src/sim_project/ ├── utils/ │ ├── timeutil.py │ ├── fileutil.py │ └── networkutil.py每个小工具文件尽量不引用项目其他模块保证它能在任何地方被导入。这才能让工具层成为项目的稳定基座。5. 一套可以直接复制的项目骨架5.1 目录结构与职责光讲道理不如直接给模板。下面是我目前用得最顺的一套骨架适合多数中型 Python 项目不管是 Web 服务还是命令行工具都能套。sim_project/ ├── src/ │ └── sim_project/ │ ├── __init__.py │ ├── __main__.py │ ├── cli.py │ ├── config/ │ │ ├── __init__.py │ │ └── settings.py │ ├── models/ │ │ ├── __init__.py │ │ └── user.py │ ├── repositories/ │ │ ├── __init__.py │ │ └── user_repo.py │ ├── services/ │ │ ├── __init__.py │ │ └── user_service.py │ └── utils/ │ ├── __init__.py │ ├── timeutil.py │ └── fileutil.py ├── tests/ │ ├── conftest.py │ ├── test_user_service.py │ └── fixtures/ ├── docs/ └── pyproject.toml这个骨架的核心思想是models只放数据结构和校验逻辑repositories只负责数据读写services放业务规则config统一管理配置utils放不依赖业务的工具函数。层次之间有一个明确的依赖方向services → repositories → modelsconfig和utils是公共基座。5.2 核心代码示例我挑三个关键文件展示一下写法。config/settings.py负责路径和配置基础信息from pathlib import Path import os class Settings: app_name: str os.getenv(APP_NAME, sim-project) log_level: str os.getenv(LOG_LEVEL, INFO) data_dir: Path Path(os.getenv(DATA_DIR, ./data)).resolve() property def log_path(self) - Path: return self.data_dir / app.log settings Settings()repositories/user_repo.py只做存取不碰业务判断from ..models.user import User class UserRepo: def __init__(self): self._store: dict[str, User] {} def save(self, user: User) - None: self._store[user.id] user def get(self, user_id: str) - User | None: return self._store.get(user_id)services/user_service.py处理业务规则依赖注入 repofrom ..repositories.user_repo import UserRepo from ..models.user import User class UserService: def __init__(self, repo: UserRepo): self._repo repo def create_user(self, user_id: str, name: str) - User: user User(iduser_id, namename) self._repo.save(user) return user注意UserService不在内部自己UserRepo()而是通过构造参数接收。这样未来你想换成数据库实现只需要替换传入对象业务逻辑完全不用改。这种写法比“在 service 里直接 new 依赖”好测得多也符合低耦合原则。cli.py里接起来import argparse from .config.settings import settings from .repositories.user_repo import UserRepo from .services.user_service import UserService def main() - None: parser argparse.ArgumentParser(progsim-project) subparsers parser.add_subparsers(destcommand, requiredTrue) create_parser subparsers.add_parser(create-user) create_parser.add_argument(--id, requiredTrue) create_parser.add_argument(--name, requiredTrue) args parser.parse_args() repo UserRepo() service UserService(repo) if args.command create-user: user service.create_user(args.id, args.name) print(fcreated user: {user.name} (id{user.id}))配套的 pyproject.toml 注册脚本入口[project.scripts] sim-project sim_project.cli:main装完依赖后你在任何目录敲sim-project create-user --id 1 --name Tom就能跑起来完全不需要关心入口文件在哪。5.3 测试的目录适配测试代码放tests/但测试导入被测代码的方式需要注意。最省心的方案是先安装项目pip install -e .。安装后sim_project会出现在你的虚拟环境里测试文件直接用from sim_project.services.user_service import UserService绝对导入即可。tests/conftest.py里可以定义公共 fixtureimport pytest from sim_project.repositories.user_repo import UserRepo from sim_project.services.user_service import UserService pytest.fixture def user_service(): repo UserRepo() return UserService(repo)tests/test_user_service.pydef test_create_user(user_service): user user_service.create_user(1, Ada) assert user.name Ada这套测试方案不需要在测试里折腾sys.path也不需要把测试目录塞进项目包干净又稳定。如果某个项目还没有做 editable 安装pytest 通常会以 rootdir 和 conftest 所在目录作为路径基准也常常能工作但它依赖你运行 pytest 的目录不如先安装来得可靠。6. 常见问题速查与避坑手册6.1 高频问题速查表我把这几年项目里反复出现的结构相关问题整理成一张表你遇到对应症状直接去查方案。现象根因解决方案ImportError: cannot import name x循环导入或导入时机过早优先抽公共依赖或把 import 移到函数内部attempted relative import with no known parent package包内模块被当作脚本直接运行改用python -m 包名.模块启动ModuleNotFoundError: No module named my_project项目未安装或根目录不在 sys.pathpip install -e .或确认工作目录用python main.py能跑用python -m报错入口读取路径依赖当前工作目录改用基于包文件所在位置的 pathlib 路径测试里导入被测代码失败未安装包 / conftest 路径设置问题先安装 editable测试用绝对导入项目根目录出现大量__pycache__默认编译缓存未忽略.gitignore加入__pycache__/,*.pyc改了代码但运行结果没变指向旧包路径检查是否重复安装、多路径同名包清理.venv重装自己写的logging.py覆盖标准库模块名与标准库冲突改用更具体的名称如log_config.py6.2 深挖几个最隐蔽的坑表格里有两项我觉得值得展开。第一个是“测试导入失败但代码能跑”。这种事经常出在 src 布局里你在项目根目录跑 pytest它的 sys.path 会把根目录加进去但包在src/下面Python 并不知道去src里找。如果没做 editable 安装测试里from sim_project import ...就会报找不到模块。对策就是老老实实pip install -e .让包以标准方式进入环境。第二个是“改了代码但结果不变”。这个坑很隐蔽通常是因为你的虚拟环境里同时存在不同路径下的同名包。比如你之前把项目装到了全局 site-packages后来又改了项目源码但只更新了本地目录运行时 import 的却是旧版本。排查方法是在 REPL 里执行import sim_project print(sim_project.__file__)看输出路径是否是你正在开发的路径。如果不是就把它卸载重装。这个技巧能解决我遇到的绝大多数“改了没反应”问题。6.3 虚拟环境与依赖污染组织的再好的结构如果依赖环境是乱的一样跑不起来。我强烈建议每个项目使用独立虚拟环境。现代写法是python -m venv .venv source .venv/bin/activate pip install -e .[dev]虚拟环境目录命名统一用.venv并写进.gitignore。不要在系统 Python 里直接pip install因为不同项目的依赖版本会互相覆盖。等到你的项目里已经有几十个依赖项时再想理清哪个是哪个就太晚了。依赖描述也应该分层pyproject.toml里的dependencies写运行必需依赖[project.optional-dependencies]里的dev写测试、代码检查、构建工具依赖。部署环境用pip install .开发环境用pip install -e .[dev]两套依赖互不干扰。最后再分享一个实际经验结构重构不要一步到位越大的项目越要按依赖方向渐进式调整。你先处理入口和路径问题让项目在python -m模式下跑通然后把配置统一进settings.py接着按业务域拆分 services 和 repositories最后再处理utils这些公共基座。每完成一步跑一遍测试确认没有破坏行为再继续下一步。代码组织不是一个“漂亮”的任务它是一项投资投入越早后期加需求时你就越轻松。