1. 项目结构:为什么这件事值得认真对待
说句实在话,我见过太多Python项目,刚开始写的时候就是一个main.py,跑通之后往里面疯狂堆函数,三个月后变成三千行的大杂烩。等到要加新功能、换依赖、写测试的时候,光是搞清楚哪些函数被谁调用就得翻半天。更麻烦的是,第一次部署时就愣住了:代码在哪一层目录?配置文件放哪?日志写到哪去?排除掉代码本身的问题,项目结构乱七八糟就是最大的隐性成本。
Python项目结构和代码本身一样重要,但大部分人都是在踩过坑之后才意识到这一点。本文就从实际工程的角度,把项目结构这件事拆开讲清楚:一个像样的Python项目应该长什么样,前后端、数据、测试、配置各自该放哪里,为什么有些项目要分src和tests,有些项目没必要,怎样迁移现有代码不被炸掉。同时,我也会结合目前主流的FastAPI项目目录结构,以及量化策略代码、LSTM模型训练代码、爬虫脚本这类具体场景,给出可以直接抄的骨架方案。
适合谁来读?正在从脚本过渡到正经项目的Python初学者,接手过别人一坨乱代码的开发者,还有准备把自己的小工具工程化、准备上测试和CI的人。我尽量不堆空理论,每一层目录,都会告诉你当初我是怎么踩的坑、为什么这么放。
2. 设计思路与目录骨架拆解
2.1 从脚本到项目的路径依赖问题
先说一个最常见的问题:import报错。很多初学者把各个模块平铺在一个文件夹里,b模块想引用a模块,直接import a,结果一运行就报ModuleNotFoundError。原因在于Python的模块查找依赖于工作目录和sys.path,脚本跑起来的时候,解释器默认把当前工作目录加进去,而不是代码所在目录。只要你换了一个启动方式,比如从项目根目录跑子目录里的脚本,或者用pytest执行测试,路径一下就不对了。
所以项目结构的第一要务,不是美观,而是让模块导入关系稳定。把代码收敛进一个顶层包,在项目根目录加pyproject.toml或requirements.txt,用pip install -e .把它装成可导入的本地包,之后所有模块通过包的路径引用。这样无论你从哪个目录启动,解释器都能根据安装信息找到包,彻底摆脱“脚本在哪个路径下才能跑”的心智负担。
我常用的骨架长这样:
my_project/ ├── src/ │ └── my_project/ │ ├── __init__.py │ ├── config.py │ ├── models/ │ ├── services/ │ ├── utils/ │ └── main.py ├── tests/ │ ├── conftest.py │ └── test_services.py ├── data/ │ ├── raw/ │ └── processed/ ├── scripts/ ├── docs/ ├── pyproject.toml ├── README.md └── .env.example这个布局叫src布局。它多套了一层src,看起来有点绕,但实际作用很大:它把你的项目代码和测试代码、脚本代码隔离开,强制你通过安装包而不是“路径碰巧对了”来导入,从根子上避免了导入错乱。
2.2 各目录的职责划分与边界
再逐层说清楚每个目录该放什么。
src/my_project/是主包,里面再按功能分模块。注意每个模块目录都配上__init__.py,Python 3.3之后这文件可以是空的,但它的存在明确告诉解释器:这是一个包。我习惯在里面写一行包的简短描述,比如"""services layer: business logic""",等到生成文档的时候能省很多事。
models/存放数据模型或与数据库对应的ORM类,比如SQLAlchemy的Base子类、Pydantic的Schema定义;services/放业务逻辑,比如订单计算、策略回测的核心函数;utils/放与环境无关的通用函数,比如日期解析、文件IO辅助。这样划分,是让代码只靠模块就能说明自己的作用,而不是靠文件名加注释去猜。
tests/与代码包平行,不在主包内部。如果测试代码所在的路径和被测代码在同一层,pytest会方便一些,但主包的内部细节也会因为导入路径而暴露。用src布局后,pytest只要在项目根目录运行,它就能按安装好的包路径导入my_project,不依赖任何相对路径。
data/隔离原始数据,避免把几千兆的CSV放进版本库。raw/存不可再生的外来数据,如API导出的JSON、数据库备份;processed/存清洗后的数据,比如特征工程后的训练集。这些目录通常要写进.gitignore,只保留.gitkeep占位。
scripts/放一次性操作脚本,如数据迁移、初始化数据库、定时任务。它和主包的区别在于,这里面的脚本不被别的模块引用,只作为命令入口。很多人把这类操作函数塞进utils,坏了——utils应该只有被引用的纯函数,有副作用的操作脚本单独放,后续搜索和维护会清晰很多。
docs/放文档,如果只是Markdown笔记,散落在根目录也行;如果项目到了要写API文档、设计文档的阶段,就统一收进docs/。
2.3 Flat布局与src布局的取舍
src布局不是唯一的答案。小到代码量不超过两千行、只有两三个模块的个人脚本项目,我建议直接扁平布局:
my_project/ ├── my_project/ │ ├── __init__.py │ ├── core.py │ └── utils.py ├── tests/ ├── requirements.txt └── README.md这种布局省掉了src层,简单直接,对于快速原型、爬虫脚本、教学演示很友好。它的问题是项目变复杂后,项目根目录可能混入生成的文件和数据,包和根目录的边界变得模糊。什么时候从扁平切到src布局?我个人的判断标准是:当项目开始涉及数据库迁移、多个服务类模块、需要写单元测试而不只是手工跑脚本时,就可以考虑迁移了。迁移动作不复杂,把代码目录整体挪进src/,更新导入路径,重新pip install -e .即可。
如果你用的是FastAPI这类Web框架,项目结构还会多一些Web特有的层次,我们放在下一章细讲。
3. 从零搭建:一个可落地的项目骨架
3.1 初始化项目根目录与版本控制
手动创建这样的目录树并不难,但我建议直接用命令行初始化,顺便把Git仓库一起建好:
mkdir my_project cd my_project git init mkdir -p src/my_project/models src/my_project/services src/my_project/utils mkdir -p tests data/raw data/processed scripts docs touch README.md touch .gitignore touch src/my_project/__init__.py touch src/my_project/config.py touch tests/conftest.pygit init这一步很多人会拖到项目快写完才做,我强烈建议一开始就建仓库,否则等你写完才知道要弃用Git,历史已经在本地了,重建成本更高。
.gitignore里至少要有这些:
__pycache__/ *.py[cod] *.egg-info/ .env .venv/ venv/ dist/ build/ data/raw/* data/processed/*data/下面的内容到底要不要进Git,一直是团队协作的争论点。我个人的做法是:raw/和processed/都忽略,但保留目录结构,用.gitkeep文件占位。如果数据量不大且团队都在内网,也可以考虑放进私有仓库,但默认还是忽略,避免误提交大文件导致仓库膨胀。
3.2 依赖管理:从requirements到pyproject
老牌做法是requirements.txt,把安装的包和版本都写进去,用pip freeze > requirements.txt生成。这种方式在只有一个环境、一个部署目标的时候足够,但一旦出现开发依赖和生产依赖分离,或者有人升级了某个传递依赖导致环境不一致,requirements.txt就捉襟见肘了。
我现在的首选是pyproject.toml,它把项目元数据、构建信息、运行依赖全部收拢到一个文件。以FastAPI项目为例,一个最小可用的pyproject.toml长这样:
[project] name = "my_project" version = "0.1.0" description = "A sample project structure" requires-python = ">=3.10" dependencies = [ "fastapi>=0.110,<1.0", "uvicorn[standard]>=0.30,<1.0", "sqlalchemy>=2.0,<3.0", "pydantic>=2.5,<3.0", ] [project.optional-dependencies] dev = [ "pytest>=8.0", "ruff>=0.4", ] [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"]注意到[tool.setuptools.packages.find] where = ["src"]这一行,它告诉setuptools去src/下找包。有了配置之后,在项目根目录执行:
pip install -e ".[dev]"它会把my_project以可编辑模式安装到当前环境的site-packages,命令直接可用,pytest也能正确导入包。
很多人用pyproject.toml时踩过坑:写完配置后运行pip install -e .,结果提示找不到包。大概率是where配置和目录实际不匹配。你写了src布局,where里就要填["src"];扁平布局则直接删掉[tool.setuptools.packages.find]整段,让setuptools默认搜索当前目录。记住这一点,可以省掉半小时排查时间。
还有一种常见组合是requirements.txt管部署、pyproject.toml管开发,靠requirements.txt里写-e .指向项目本身。这种做法兼容老流程,但会带来两份依赖清单,需要保持同步,新项目不太建议。
3.3 config与环境变量处理
配置管理是项目结构里容易被忽视的部分。很多人直接在代码里写死路径、数据库地址、API密钥,结果换台机器、换个环境就要改代码。正确的方式是把配置和代码分离。
我一般这样处理:
# src/my_project/config.py from pathlib import Path import os BASE_DIR = Path(__file__).resolve().parent.parent DATA_DIR = BASE_DIR / "data" RAW_DATA_DIR = DATA_DIR / "raw" PROCESSED_DATA_DIR = DATA_DIR / "processed" DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db") API_KEY = os.getenv("API_KEY", "")密钥之类的敏感信息不要写进代码仓库,而是放在.env文件里,并在.gitignore中忽略它,给一个.env.example作为模板。Python侧可以用python-dotenv读取:
from dotenv import load_dotenv load_dotenv()基于config.py统一管理配置,还有一个额外好处:所有路径都通过BASE_DIR推算,不依赖当前工作目录。不管你在根目录跑还是在一堆深层子目录里跑测试,路径都不会乱。
4. 核心细节解析与实操要点
4.1 包内部的相对导入与绝对导入
项目结构定下来之后,最影响日常开发体验的就是导入方式。社区里一直有绝对导入和相对导入之争。我的建议很简单:包内部统一用绝对导入。
# 推荐 from my_project.services.order_service import create_order # 不推荐 from .order_service import create_order绝对导入的好处是代码可读性高,任何人一看就知道这个模块来自哪里;重命名模块时,IDE的全局重构也能正确处理。相对导入from . import xxx在深层嵌套时就容易看晕,而且一旦模块被包装成exe或嵌入其他项目,相对导入经常会出问题。我自己的项目中,只有__init__.py里做重导出时会用到相对导入,其他一律绝对导入。
用绝对导入的前提,是项目已经安装为可导入的包。这时候你会在很多教程里看到这样的启动方式:
cd src python -m my_project.main这其实是旧思路——手动切换目录让包可见。在src布局下这反而容易出错,因为cd src后项目根目录的tests和data就找不到了。正确的方式是在项目根目录下运行:
python -m my_project.main前提是my_project已经通过pip install -e .安装。如果没安装,直接这样跑会报找不到模块,这也是很多人在src布局下第一次启动失败的常见原因。
4.2__init__.py的正确用法与重导出
__init__.py不是用来写业务逻辑的。它的作用是定义包的对外接口,也就是重导出。比如services包里有很多服务模块,你不希望使用者直接from my_project.services.order_service import create_order,而是希望from my_project.services import create_order,就在services/__init__.py里写:
from my_project.services.order_service import create_order from my_project.services.user_service import get_user这样设计接口有一个好处:内部模块路径怎么变,外部接口可以保持不变。这是一层很好的封装。同时__init__.py也可以顺便放包的版本号、元信息,比如:
__version__ = "0.1.0"注意,__init__.py里不要做重量级导入,否则import my_project会连带加载一大堆依赖,启动变慢,还可能制造循环导入。我踩过这种坑:当时把一个数据库连接池放进了__init__.py,但凡有人导入包里的任意一个模块,都会初始化连接池,测试的时候直接连上生产库,差点出事。所以__init__.py里的内容要精,只放纯重导出和版本号。
4.3 测试目录的组织方式与conftest
测试不是简单丢几个test_*.py文件就行,它的结构会影响你写测试的积极性。一个让我很受用的约定是:测试文件的路径映射到被测模块的路径。比如被测模块是my_project/services/order_service.py,测试文件就放在tests/test_services/test_order_service.py。这样查看覆盖率报告时,哪些代码没测到一目了然,新增模块时也清楚该在哪里加测试。
conftest.py是pytest的固定入口,放共享的fixture。它放在tests/根目录,作用范围覆盖所有测试文件。常见的fixture比如临时数据库、测试客户端、固定路径目录:
# tests/conftest.py import pytest from pathlib import Path @pytest.fixture def sample_data_dir(): return Path(__file__).parent / "sample_data" @pytest.fixture def db_session(tmp_path): db_path = tmp_path / "test.db" # 初始化数据库连接 yield session session.close()用tmp_path内置fixture而不是自己指定临时目录路径,pytest会自动为你创建独立临时目录,测试结束后自动清理,不会污染项目里的data/目录。这个细节很多人不知道,但能避免大量“测试环境垃圾文件”的麻烦。
4.4 scripts与主包的可执行入口
如果项目需要命令行工具,不要写一个main.py然后在里面if __name__ == '__main__'放一堆逻辑。更清晰的方式是把入口点声明在pyproject.toml里,用[project.scripts]定义控制台命令:
[project.scripts] my-tool = "my_project.main:main"在src/my_project/main.py定义:
def main(): # 启动逻辑 pass安装项目后,终端里直接敲my-tool就能运行,系统会注入项目路径到sys.path,不用手动维护启动脚本。这种方式在FastAPI项目里特别常见,你会发现很多项目的启动脚本是用uvicorn作为入口,业务代码里没有多余的if __name__ == '__main__'。
如果你想保留手动运行的方式,记得用python -m my_project.main而不是python src/my_project/main.py。后者会让Python误以为my_project是个普通目录,遇到跨模块导入就会炸。
5. 不同场景下的结构适配
5.1 FastAPI项目目录结构实例
FastAPI是目前最常用的Python Web框架之一,它的项目结构在通用骨架之上,多出了Web框架特有的几个层次。这里给一个实际项目中验证过的结构:
fastapi_app/ ├── app/ │ ├── api/ │ │ ├── v1/ │ │ │ ├── endpoints/ │ │ │ │ ├── users.py │ │ │ │ ├── orders.py │ │ │ │ └── health.py │ │ │ └── __init__.py │ │ └── deps.py │ ├── core/ │ │ ├── config.py │ │ ├── security.py │ │ └── database.py │ ├── models/ │ │ └── user.py │ ├── schemas/ │ │ └── user.py │ ├── services/ │ │ └── user_service.py │ ├── main.py │ └── __init__.py ├── tests/ ├── alembic/ │ └── versions/ ├── .env.example └── pyproject.tomlapi/v1/endpoints放路由处理函数,每个模块只处理HTTP请求的接收和响应组装,业务逻辑下沉到services。core放配置、数据库连接、安全认证等基础支撑。models放ORM类,schemas放Pydantic模型,两者职责不同:ORM对应数据库表结构,Pydantic对应API请求/响应结构。这个区分是FastAPI项目和Django项目最大的差别,也是新手最容易混淆的地方。
你可能会问,为什么不需要utils目录?FastAPI项目里的工具函数,在services和core之间来回挪,其实没有固定的归属。我的经验是不要一开始就建utils,等同样的函数至少出现两三次之后再提升为公共模块,否则utils会变成一个小垃圾场。
5.2 爬虫与数据处理项目结构
爬虫项目和数据工程项目的代码组织,和Web项目差别很大。它们通常是“脚本 + 库”混用的形态。我给爬虫项目推荐的骨架是这样的:
crawler_project/ ├── crawler/ │ ├── spiders/ │ │ ├── base.py │ │ └── product_spider.py │ ├── pipelines/ │ │ ├── clean.py │ │ └── storage.py │ ├── utils/ │ │ └── requests_with_retry.py │ └── config.py ├── data/ │ ├── raw/ │ └── processed/ ├── scripts/ │ └── run_spider.py ├── tests/ └── pyproject.toml核心思路是:把爬虫逻辑按职责拆成“请求、解析、清洗、存储”四层,每一层都可以单独测试。尤其要注意的是,爬虫项目特别依赖数据目录的规范性。如果你不确定raw/和processed/哪个数据对应哪个时间批次,建议在data/raw/下再加一层日期目录,比如data/raw/2024-06-01/,这样重跑时不会互相覆盖。
5.3 机器学习项目与量化交易策略结构
机器学习项目和量化策略项目又有自己的特点。这类项目不能简单用models表示业务模型,因为模型是训练出来的产物,并非代码文件。我在做LSTM模型训练和量化策略回测时,长期用的是下面这套结构:
ml_project/ ├── src/ │ └── ml_project/ │ ├── data/ │ │ ├── loader.py │ │ ├── preprocess.py │ │ └── features.py │ ├── models/ │ │ ├── train.py │ │ └── predict.py │ ├── backtest/ │ │ └── engine.py │ └── config.py ├── notebooks/ │ ├── 01_eda.ipynb │ └── 02_model_demo.ipynb ├── data/ │ ├── raw/ │ ├── processed/ │ └── models/ │ ├── lstm_v1.pt │ └── xgboost_v1.json ├── scripts/ │ ├── run_training.py │ └── run_backtest.py ├── tests/ └── pyproject.tomlnotebooks/放探索性分析和模型演示。早期我犯过一个大错误:把训练逻辑直接写进notebook,等到要复现训练结果时,因为notebook单元格顺序错乱,怎么都跑不出当时的效果。现在我的原则是:notebook只做快速探索和可视化,一旦确认方向,马上把代码固化到src下的正式模块,notebook里只保留结果展示,不保留关键训练逻辑。
data/models/是模型产物的存放目录,它和raw/、processed/并列但职责不同,通常也需要忽略版本控制,因为一个几十MB的.pt文件会对仓库体积产生很大压力。如果确实需要版本化管理模型产物,可以用专门的文件存储服务或专用的模型仓库,不建议硬塞进Git。
量化交易策略项目的结构精神类似,但多了一个回测引擎的抽象。策略策略代码与回测引擎代码分离非常重要,否则你每次改策略,都要重跑一遍回测基础设施。把engine.py独立出来,策略只是传入引擎的参数或子类实现,后续新增策略的边际成本就会低很多。
6. 常见问题与排查技巧实录
6.1 循环导入:为什么from A import B和from B import A会死
循环导入是Python项目结构化之后最容易碰到的坑。当你把代码按模块拆分成多个文件,模块之间互相引用就会发生。
举个例子:
# a.py from b import func_b def func_a(): func_b() # b.py from a import func_a def func_b(): func_a()跑起来时Python解释器会按sys.path加载模块,加载a时发现from b import func_b,于是转去加载b,加载b时又发现需要加载a,此时a尚未加载完,于是报错ImportError: cannot import name 'func_b' from partially initialized module 'a'。
解决办法有几种:
- 把公共函数移到第三个模块,比如
common.py,让a和b都依赖它,避免互相引用。 - 把导入语句移到函数内部,延迟导入。
- 重新思考职责划分。大多数循环导入,本质是设计上出了问题,某个模块承担了两个不相干的职责。我具体遇到过的一个案例是:
models目录里的ORM类需要引用services里的业务方法,为了拿某个状态字段去查表,结果产生了循环依赖。正确做法是把查询逻辑下沉到services,models只负责数据定义,两者分离,循环自然消解。
6.2 迁移旧项目时如何不炸
把散乱的平铺脚本迁移到src布局,看起来是文件夹挪一挪,但真正动手往往有各种报错。我迁移过几次后总结了一套稳妥的流程:
先把所有模块目录迁移到src/my_project/下,保留原有的包名路径。接着全项目搜索import语句,把项目内部的导入统一改为from my_project.xxx import yyy。然后创建pyproject.toml,配置好[tool.setuptools.packages.find],执行pip install -e .。最后运行测试,如果报导入错误,优先检查是不是有模块遗漏了__init__.py。
有一个很容易漏的点:迁移前项目内可能有一些相对导入,比如from ..utils import helper。这类相对导入在迁移后会变得不可用,因为包的层级变化了。迁移时建议全部改成绝对导入,省得后续再改。
6.3 pytest收集不到测试文件怎么办
写好了tests/目录,运行pytest却提示no tests ran。常见原因是测试文件名不符合test_*.py规则,或者pytest.ini、pyproject.toml中python_files配置把模式改得过于严格。另一个常见原因是项目目录里__init__.py导致的路径混淆,pytest在递归收集测试时,如果某个测试文件所在的目录没有__init__.py,它默认认为这是一个普通目录,测试模块的导入名就是test_order_service;如果目录有__init__.py,则导入名变成tests.test_services.test_order_service,这一差异会影响conftest.py的作用域判断。
我的建议是:tests/目录不要放__init__.py,让pytest用根目录插入路径的方式处理。这样conftest.py的fixture作用域更直观。如果你在pyproject.toml里配置了testpaths = ["tests"],还要确认路径和目录名一致,不要出现tests/和test/两个目录同时存在的情况。
6.4 运行入口找不到包路径
有一种很典型的报错:在项目根目录运行python src/my_project/main.py,然后import自己项目内的模块,报ModuleNotFoundError。原因前面提过——Python会把脚本所在目录,也就是src/my_project/作为sys.path的第一个条目,而不是项目根目录。这就导致my_project包作为整体找不到了。
解决方式:
# 正确 python -m my_project.main # 或者安装后直接用控制台命令 my-tool如果你用VS Code,还需要注意调试器的cwd和python.analysis.extraPaths配置。调试F5之前先确认工作目录是项目根目录,否则即使命令行跑得通,调试器也可能因为路径不同而报模块找不到。
7. 我最后想分享的一个组织技巧
上面这些结构,我都实际用过,从三五百行的爬虫脚本,到几千行的FastAPI后端、LSTM训练工程,这套“合理拆分 + 统一导入 + 目录隔离”的方法论是通吃的。最后一个技巧是我个人非常依赖的:给目录里的__init__.py写一句话注释,注明这个包是什么、被谁引用。等到半年后你回来看代码,这些注释能帮你快速定位该去哪个目录改东西,省掉满项目搜索的功夫。
Python项目结构没有绝对标准,也没有银弹,它更像是一个项目从小到大的自然生长过程。你不需要一开始就照着教科书把所有目录都建好,而是应该在新模块出现的时候,有意识地问一句:它应该放在哪里?它会被谁引用?它会不会让一个原本清晰的包变得臃肿?保持这种敏感,比记住任何一份模板都重要。项目是写给未来的自己看的,把文件放对位置,就是给未来的自己留的一条清晰的路。