尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python项目结构设计与目录最佳实践:从脚本到工程化骨架

Python项目结构设计与目录最佳实践:从脚本到工程化骨架 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__.pyPython 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 [ fastapi0.110,1.0, uvicorn[standard]0.30,1.0, sqlalchemy2.0,3.0, pydantic2.5,3.0, ] [project.optional-dependencies] dev [ pytest8.0, ruff0.4, ] [build-system] requires [setuptools68] 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里的业务方法为了拿某个状态字段去查表结果产生了循环依赖。正确做法是把查询逻辑下沉到servicesmodels只负责数据定义两者分离循环自然消解。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项目结构没有绝对标准也没有银弹它更像是一个项目从小到大的自然生长过程。你不需要一开始就照着教科书把所有目录都建好而是应该在新模块出现的时候有意识地问一句它应该放在哪里它会被谁引用它会不会让一个原本清晰的包变得臃肿保持这种敏感比记住任何一份模板都重要。项目是写给未来的自己看的把文件放对位置就是给未来的自己留的一条清晰的路。
返回列表