Python项目结构最佳实践:从脚本到可维护工程的完整指南
2026/9/8 16:37:07 网站建设 项目流程

先回答很多刚接触 Python 的人都会问的一个问题:真有必要花时间琢磨“Python项目结构”这件事吗?直接在一个.py文件里从上写到下,跑通不就行了?我的回答是:如果你只写十行脚本自己用,当然可以,怎么舒服怎么来。但当你开始写一个会被复用、被测试、被别人甚至“三个月后的自己”维护的项目时,文件随便乱放带来的代价会立刻显现——导入失败、路径找不到、功能不敢改、测试跑不起来。几乎每个从脚本过渡到项目的开发者,都会在这上面狠狠踩几脚。所以我想用这篇东西,把Python项目结构从设计思路到落地实操完整走一遍,结合我这些年折腾爬虫、量化回测、模型复现这类实际项目的经验,讲清楚什么样的结构适合什么场景、每一步为什么这么放、以及最常见的那几个坑到底怎么避开。无论你是刚学会print('hello')的新手,还是想让团队协作更顺畅的开发者,这篇文章都能当作一份能直接抄作业的参考。

1. 为什么项目结构能决定你的开发效率

1.1 项目结构本质是在管理“复杂度”

我见过很多初学者以为“项目结构”就是把文件放到不同的文件夹里,让目录看起来整齐。这个理解没错,但只看到了表面。项目结构真正解决的问题,是代码之间的“依赖关系”和“变动风险”。

举个例子:一个量化交易策略的代码,如果你把所有东西都堆在一个main.py里,最开始可能只有 200 行,你觉得还好。但当你加了数据下载、指标计算、回测引擎、交易信号、参数优化之后,文件涨到 2000 行,这时候你想改“止损条件”,就必须在 2000 行里反复滚动找人眼,改完之后还要担心是不是影响了别的地方。如果你一开始就把数据获取、策略逻辑、回测框架、可视化拆成独立模块,每个模块只负责一件事,那么改“止损逻辑”时,你只需要打开策略模块,其他部分完全不用动。

结构化的本质,是让你把“复杂度”拆散到不同的小盒子里,每个盒子内部可以复杂,但盒子之间的接口尽量简单。Python 的模块和包机制天生就是干这个的。很多人写 Python 一上来就from xxx import *,并且文件之间互相乱引,导致项目依赖图完全是一团乱麻,这种代码基本没有维护性可言。

一个健康的项目结构,应该让人从目录树就能大致猜出这个项目有什么功能,从哪里开始跑,核心代码在哪,测试在哪。读代码的人不需要深入每个文件,就能对项目有一个整体认识。

1.2 结构混乱的典型症状与后果

什么样的代码算“结构有问题”?我总结了几个高频症状,你可以对照一下自己的项目:

  • import语句报错,尤其换一台机器或换了一个目录就报错,说明有人直接用“当前运行目录”来导入,而不是基于项目根目录。
  • 一个文件几千行,函数之间靠全局变量传递数据,改一处就崩三处。
  • 没有独立的测试目录,想跑测试得先把整个业务逻辑跑一遍。
  • 配置参数散落在各个文件里,数据库地址、API Key、阈值全写死在代码里,这不仅是结构问题,还是安全问题。
  • 想写单元测试却导不进来被测模块,因为项目根目录没进sys.path

这些症状的根因,几乎都不是“你写代码的水平不行”,而是“一开始没有想好代码放在哪、怎么互相调用”。尤其是 Python 这种靠路径和模块名解析导入的语言,目录结构直接决定了你import是否顺畅。这跟 Java 强制包名和目录一致还不一样,Python 太灵活了,灵活到你如果不自己定规则,项目就会用自己的“混乱”来惩罚你。

2. Python 项目的常见结构模式与选型思路

2.1 从单脚本到包:Python 项目的规模分级

没有一种结构是“放之四海而皆准”的,因为项目结构必须匹配项目规模和场景。我通常把 Python 项目分为四个量级,不同量级对应不同的组织方式。

第一级是纯脚本级,场景是数据处理、文件整理、爬个小网站这类“跑一次就完事”的代码。这种项目通常只有一个或两三个.py文件,不需要什么复杂目录,但至少应该保证一个文件只做一类事。

第二级是小项目级,功能开始多起来,有配置、有依赖、有多个模块互相调用,比如带界面的小工具、一个小型 Web 服务、一个自动化脚本集合。这时候就需要引入包结构、配置管理和依赖清单了。

第三级是标准应用级,比如一个完整的 Web 后端、一个机器学习训练项目、一个量化回测系统。这种项目要跑得长久,必须有 src 布局、测试目录、文档目录、配置管理、命令行入口封装。

第四级是多包/工作区级,比如多个服务共享一套基础库,或者一个代码仓库里有算法、API、调度等多个独立可部署的组件。这种情况可能要上 monorepo 结构或者多个独立仓库配合发布工具来管理。

我强烈建议:新手不要一上来就套用第四级的复杂结构,否则你会被空目录和组织规则劝退。但也不要用第一级的方式去写第三级的项目,否则后面重构的代价比一开始认真设计大得多。

2.2 不同项目规模的目录结构参考

直接给三个我常用的模板,大家可以根据自己项目的情况对号入座。

先看最入门的小项目结构,适合几百行到一两千行的小工具:

my_tool/ ├── main.py ├── config.py ├── utils.py ├── requirements.txt └── README.md

这种结构适合“一个主入口,两三个辅助模块”的场景。main.py负责入口和流程编排,utils.py放通用函数,config.py集中放配置。也许你在真实场景中需要更多文件,但核心思路是:主流程、工具函数、配置分离,就够了。

再看我比较推荐的标准项目结构,适合 Web 应用、数据处理、机器学习项目这类正经要长期维护的:

my_project/ ├── src/ │ └── my_project/ │ ├── __init__.py │ ├── cli.py │ ├── config.py │ ├── models/ │ ├── services/ │ ├── utils/ │ └── ... ├── tests/ │ ├── __init__.py │ ├── test_config.py │ └── test_services.py ├── docs/ ├── scripts/ ├── pyproject.toml ├── README.md └── .gitignore

这是典型的 src 布局。接下来我用大量篇幅展开讲这种结构里每个目录的作用和设计逻辑,因为这正是很多 Python 项目最欠缺的部分。

2.3 两种主流结构对比:扁平布局与 src 布局

我上面说了 src 布局,那另一种很流行的是“扁平布局”,也就是项目根目录直接放包文件夹:

my_project/ ├── my_project/ │ ├── __init__.py │ └── ... ├── tests/ └── pyproject.toml

两种方式有什么区别?扁平布局在早期很常见,因为简单直接,你把my_project文件夹放在项目根目录,然后从项目根目录运行import my_project能直接找到。但问题是,如果你在项目根目录下的测试文件、脚本、配置里也存在同名模块,运行pytest或某个脚本时,Python 会把项目根目录当作第一顺位的搜索路径,这样导入的可能是“当前目录下的代码”,而不是你通过 pip 安装的那个版本。

src 布局的核心价值在于:把真正的包代码全部归置到src/下一层,项目根目录下不再直接暴露可导入的顶层包。这样你运行测试时,代码不能“碰巧”被根目录路径导入,而是必须通过安装(比如pip install -e .)才能在环境中可见。这保证了测试时导进来的代码和用户实际安装的代码一致,避免了“我本地跑得好好的,一打包就崩”这种魔幻问题。

如果你是第一次接触 src 布局,可能会觉得多套一层文件夹麻烦。但事实是,这种结构调整带来的收益非常确定。我后来所有正式项目都改成 src 布局了,最大的感受是:测试更加可信,打包意外变少,并且在项目根目录放scripts/tests/这类非包代码时,再也不会和包代码互相夹缠。

3. 核心目录与文件的职责拆解

3.1 包目录 src/my_project:代码唯一主场

在 src 布局下,你的核心业务代码全部放在src/my_project/里。这个目录就是“包”,所有模块的导入都是从它开始的。比如from my_project.services import DataLoader,Python 会去src/my_project/services.pysrc/my_project/services/里找。

包内的进一步组织也很有讲究。我建议按“业务领域”切分子包,而不是按“技术类型”切。举个例子,一个爬虫项目里你可能有下载器、解析器、存储模块,那么你可以建crawler/downloader.pycrawler/parser.pycrawler/storage.py。但如果按技术类型切,建一个utils/把所有零碎函数丢进去,很快就会变成一个无法维护的大杂烩。你想想,当你需要找一个“去重并写入数据库”的函数时,你是去crawler/storage.py里找更快,还是去utils/third_party.py里翻更快?显然是前者。

所以我的建议是:子包的划分要跟着功能走,每一个子包有一个清晰的职责描述。当一个模块文件超过三四百行,并且改动的理由不止一个时,就是时候把它拆成子包了。

__init__.py是包的初始化文件。在 Python 3 里它甚至可以是空的,单纯用来标记目录是一个包。但更好的用法是,在__init__.py中声明包的对外公共 API。比如你的包里有downloader.pyparser.py,如果它们是内部实现,那更合适的对外暴露方式是:

# src/my_project/__init__.py from .crawler import BaseCrawler from .config import load_config __all__ = ["BaseCrawler", "load_config"]

这样用户只需要from my_project import BaseCrawler,而不需要关心 BaseCrawler 是不是在my_project.crawler子模块里定义的。这给了你重构内部结构的自由度,因为外部调用方式不变,内部想怎么挪都行。

3.2 测试目录 tests:保证重构安全感的底线

很多人写了多年 Python 都没有建 tests 目录的习惯,觉得“写测试太麻烦”“小项目不需要测试”。但我见过太多人因为懒于写测试,最终被自己写的代码坑得死去活来。Python 是动态语言,没有编译器帮你查类型错误,函数内部逻辑只要稍微绕一点,不改测试直接改代码,你根本不知道哪里会被踩雷。

一个基础的测试目录长这样:

tests/ ├── conftest.py ├── test_config.py ├── test_services.py └── fixtures/

conftest.py是 pytest 的插件和共享 fixture 的定义点。你可以在里面写一些通用的测试夹具,比如创建临时数据库、准备测试用配置、mock 外部 HTTP 请求等。fixtures/放测试用的静态资源,比如样例 CSV、图片、JSON 文件。

写测试这件事不用追求一步到位。先把最核心的业务逻辑覆盖住,比如配置解析、数据处理的关键函数、对外 API 的返回格式。至少保证你跑pytest的时候,项目的核心功能不会在重构中悄悄挂掉。

关于测试导入的问题,我在后面问题排查章节会仔细讲。这里先剧透一个原则:用 pytest 配合 src 布局,然后在项目根目录用pip install -e .安装项目,测试里统一from my_project.xxx import yyy,避免写一堆改sys.path的“良方”。

3.3 配置与入口管理:pyproject.toml 取代散装的 setup.py

如果你到现在还在用requirements.txt管依赖、用setup.py描述包,那我建议你尽快迁移到pyproject.toml。这不是追新,而是pyproject.toml已经是 Python 打包与项目元数据的统一标准,几乎所有现代工具链都对它有良好的支持。

一个最小可用的pyproject.toml长这样:

[build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] name = "my_project" version = "0.1.0" description = "一个用于演示项目结构的小项目" requires-python = ">=3.10" dependencies = [ "requests>=2.31", "pydantic>=2.5", ] [project.optional-dependencies] dev = [ "pytest>=7", "ruff>=0.1", "mypy>=1.7", ] [project.scripts] my-project = "my_project.cli:main" [tool.setuptools.packages.find] where = ["src"]

这里有几个值得注意的点。[tool.setuptools.packages.find]where = ["src"]告诉打包工具去src目录下找包,这样pip install -e .装的就是 src 布局里的代码。[project.scripts]用于定义命令行入口,安装后你在终端直接敲my-project就能执行my_project.cli模块里的main()函数。[project.optional-dependencies]里的dev依赖可以这样安装:

pip install -e ".[dev]"

用这种方式管理项目,比把依赖全写在requirements.txt里更规范,因为你可以区分生产依赖和开发依赖,还能更精确地描述 Python 版本要求。

如果你对配置管理有兴趣,可以把config.py里的常量集中到这个文件里,也可以单独建一个config/目录或使用环境变量。但不要把敏感信息如密钥、数据库密码写进pyproject.toml,那是配置文件和环境变量该管的事,后面我细说。

3.4 辅助目录:scripts、docs、data 与 .gitignore

项目里除了核心包和测试,还会有一些辅助目录。scripts/放开发期的一次性脚本,比如初始化数据库、批量导入数据、部署辅助脚本。这些脚本不会被安装到包里,只是开发过程中用来解放双手的。

docs/放项目文档,最简单的就是README.md直接放根目录,复杂项目可以做docs/下用 MkDocs 或 Sphinx 管理。我建议先把根目录的README.md写明白,内容包括:项目是什么、怎么安装、怎么运行、怎么测试、目录结构说明。这个文件是你项目的“门面”,也是接手者最先看的东西。

data/在数据处理和算法项目里尤其常见。原始数据放data/raw/,处理后的中间数据放data/processed/,最终产出放data/output/。但如果数据文件很大,通常不会把数据提交进 Git,而是在.gitignore里忽略掉整个data/目录,再写一个数据下载或生成的脚本。

.gitignore的重要性极高。很多人刚开始用 Git 时不小心把虚拟环境目录、__pycache__.env文件、本地数据库等都提交到了仓库里,轻则仓库臃肿,重则泄露密钥。不管项目多小,一开始就把.gitignore建好是最省心的做法。一个基础的 Python.gitignore至少应该包括:

__pycache__/ *.py[cod] .venv/ venv/ dist/ build/ *.egg-info/ .env .pytest_cache/ .mypy_cache/ .ruff_cache/

如果你用 VS Code,可能还要忽略.vscode/;用 Jupyter 的话,可以考虑.ipynb_checkpoints/。每个项目的实际忽略内容可以微调,但上面这些基础项是通用的。

4. 实操记录:从空目录到一个规范 Python 项目

4.1 手动搭建最小骨架(不依赖脚手架工具)

市面上有很多项目脚手架工具,比如cookiecutter,可以用来一键生成项目结构。但如果你不理解生成出来的每一层目录是干什么的,直接用脚手架反而会让你变成“只会套模板的人”。所以我建议先手动搭一次,理解每层结构的意义之后,再决定要不要用工具加速。

手动搭建项目骨架,最简单的路径是直接用文件系统命令创建目录和文件。我现在以创建一个名为demo_project的项目为例,完整走一遍。

第一步,创建顶层目录结构:

mkdir demo_project cd demo_project mkdir src mkdir tests mkdir docs mkdir scripts

第二步,创建包目录和__init__.py

mkdir src/demo_project touch src/demo_project/__init__.py

第三步,创建核心模块。这里我给出一个带业务逻辑的service.py,一个管理配置的config.py,一个作为入口的cli.py

# src/demo_project/config.py from pathlib import Path from typing import Any import yaml class Config: def __init__(self, data: dict[str, Any]) -> None: self.data = data @classmethod def from_yaml(cls, path: Path) -> "Config": with Path(path).open("r", encoding="utf-8") as f: raw = yaml.safe_load(f) return cls(raw or {}) def get(self, key: str, default: Any = None) -> Any: return self.data.get(key, default)
# src/demo_project/service.py from pathlib import Path from demo_project.config import Config class ReportGenerator: """一个简单示例:从配置读输入输出路径,并生成文本报告。""" def __init__(self, config: Config) -> None: self.config = config def run(self) -> Path: input_path = Path(self.config.get("input_path", "input.txt")) output_path = Path(self.config.get("output_path", "output.txt")) content = input_path.read_text(encoding="utf-8") output_path.write_text( f"Report generated based on: {content}\n", encoding="utf-8" ) return output_path
# src/demo_project/cli.py import argparse from pathlib import Path from demo_project.config import Config from demo_project.service import ReportGenerator def main() -> None: parser = argparse.ArgumentParser(description="demo project") parser.add_argument("--config", type=Path, default=Path("config.yaml")) args = parser.parse_args() config = Config.from_yaml(args.config) generator = ReportGenerator(config) output_path = generator.run() print(f"Report saved to {output_path}") if __name__ == "__main__": main()

第四步,创建测试文件。注意测试里导入的是demo_project包,这也是为什么我们需要先安装项目再跑测试,否则 pytest 找不到模块:

# tests/test_service.py from pathlib import Path from demo_project.config import Config from demo_project.service import ReportGenerator def test_report_generator(tmp_path: Path) -> None: input_file = tmp_path / "input.txt" input_file.write_text("hello world", encoding="utf-8") config = Config( { "input_path": str(input_file), "output_path": str(tmp_path / "output.txt"), } ) generator = ReportGenerator(config) output_path = generator.run() assert output_path.exists() assert "hello world" in output_path.read_text(encoding="utf-8")

第五步,创建pyproject.toml,这是让包可安装的核心。配好之后执行:

pip install -e ".[dev]"

这里解释一下为什么用-e-e表示 editable 安装,也就是“开发模式”。它会创建一个指向你当前源码目录的链接,之后你改代码不需要重新安装就能生效。如果你是第一次接触,这个特性就足够让你在开发阶段离不开它了。安装完成后,你在任意目录下运行:

python -c "from demo_project.service import ReportGenerator; print('import ok')"

都能成功。这就解决了“换个目录代码就导入失败”的问题。

第六步,初始化 Git 并写好.gitignore。这一步看起来跟项目结构无关,但其实是在保护你的结构不被乱七八糟的生成物入侵。

4.2 用现代工具链管理依赖与环境

我经常被人问:虚拟环境到底用venv还是conda?包管理用pip还是poetry还是pdm?我给的答案比较务实:如果你不需要处理复杂的非 Python 原生依赖(比如某个 C 扩展库),直接用venvpip就完全够用。如果你需要做数据科学或者机器学习,而依赖里经常有 numpy、pytorch 这类二进制包,那么用 conda 或 micromamba 管理环境会更省心。

为了项目结构的一致性,我通常会做这样几件事:

  • 在项目根目录创建.venv作为虚拟环境目录,然后用.gitignore忽略它。
  • pyproject.toml并在其中维护依赖,而不是把依赖散放在多个requirements.txt
  • pip install -e ".[dev]"一次性安装开发依赖。
  • pre-commit钩子工具在提交前自动跑 ruff、black、mypy 等检查。

这些工具体系看似和“目录长什么样”无关,但它们决定了你的代码是否能被人轻松捡起来跑。举个例子,如果项目没有锁定依赖版本,半年后别人 clone 下来跑pip install -r requirements.txt,发现某个库的大版本已经变了,接口全改,这个项目的复现性就为零。所以项目结构不仅是“文件在哪”,还包括“依赖怎么描述、环境怎么重建”。

4.3 不同类型的典型 Python 项目实践对比

根据我的经验,不同类型项目的结构细节会有一些差异。比如爬虫项目,通常不是一个大包,而是一个采集任务框架,它可能长这样:

crawler_project/ ├── src/crawler_project/ │ ├── spiders/ # 每个站点一个爬虫 │ ├── pipelines/ # 数据处理流程 │ ├── middlewares/ # 下载中间件 │ ├── settings.py # 爬虫配置 │ └── run.py ├── tests/ ├── pyproject.toml └── scrapy.cfg

再比如算法训练类项目,由于它天然是“实验探索型”的,代码不像 Web 服务那样有强入口,通常会额外出现configs/目录放各种实验配置,experiments/目录记录每次实验的输出和指标,src下还会拆出data/models/trainers/等模块。

我用跑过的一个 YOLO 类目标检测项目举例。很多人直接拿官方仓库的代码跑,跑通了但对项目结构完全没有概念,想改成自己的数据时根本不知道从哪下手。这类开源项目常见的组织方式是这样的:

yolo_project/ ├── configs/ │ ├── model.yaml │ ├── data.yaml │ └── train.yaml ├── src/yolo_project/ │ ├── data/ │ │ ├── dataset.py │ │ └── transforms.py │ ├── models/ │ │ ├── backbone.py │ │ └── head.py │ ├── trainer.py │ └── utils/ ├── scripts/ │ ├── download_data.py │ └── visualize_result.py ├── tests/ ├── requirements.txt └── README.md

这种结构最大的好处是训练代码和配置完全分离。想调参数时不用翻代码改常量,直接改configs/train.yaml;想换模型结构时,在models/里加一个新文件,并在配置里指定名称,而不是在训练循环里写满 if-else。

所以你在设计自己的项目时,不要硬抄某个结构,而应该先问自己:这个项目最常被改动的点是什么?把最容易变的逻辑独立出来,项目结构就成功了大半。

另一个典型场景是量化交易策略代码。这种项目往往需要在回测与实盘之间快速切换。回测是研究逻辑,实盘是稳定执行,如果把这两类代码混在一个包里,风险很高。我通常建议按“研究环境”和“运行环境”分隔:

quant_project/ ├── src/quant_project/ │ ├── datafeed/ # 数据源相关 │ ├── strategy/ # 策略信号逻辑(核心、且纯函数化) │ ├── backtest/ # 回测引擎 │ ├── execution/ # 实盘执行与券商接口 │ ├── portfolio/ # 组合与风控 │ └── config.py ├── scripts/ │ ├── run_backtest.py │ └── run_live.py ├── tests/ ├── configs/ └── pyproject.toml

策略模块应该设计成“不依赖任何交易接口的纯逻辑模块”,输入行情和持仓,输出目标仓位。这样回测和实盘共用一套策略代码,不会因为实盘接口差异导致“回测一个样、实盘一个样”。这种模块划分的好坏,就完全通过项目结构体现出来了。

5. 项目实际运行中的配置与路径管理

5.1 为什么你的代码总在路径上出问题

运行项目时最经典的报错就是ModuleNotFoundError: No module named 'xxx',或者FileNotFoundError: [Errno 2] No such file or directory。这两个问题看着不同,根因往往都指向同一个:代码没有基于“项目根目录”来定位模块和资源文件。

很多初学者在main.py里写相对路径,比如:

with open("data/input.txt", "r") as f: ...

这种写法依赖“程序当前所在的目录”,也就是说你必须cd到项目根目录再执行python -m my_project.cli,文件才能被找到。如果你在项目根目录之外用 IDE 直接运行main.py,或者用系统的定时任务从别的路径唤起脚本,这个相对路径就失效了。

正确做法是用绝对路径或基于文件位置的定位。一个很好的工具是pathlib

from pathlib import Path # 当前文件所在目录 BASE_DIR = Path(__file__).resolve().parent # 项目根目录:src/my_project/config.py 的上一级是 src,再上一级是根目录 PROJECT_ROOT = BASE_DIR.parent.parent DATA_DIR = PROJECT_ROOT / "data"

把任何路径都基于Path(__file__).resolve()而不是基于当前工作目录,项目能在任何地方被调用。特别要注意不要在代码里手工拼字符串路径,比如os.path.join(os.getcwd(), "data"),因为getcwd()表示的是“当前进程的工作目录”,它会随启动方式而变化。

5.2 建议用轻量配置而不是写死常量

项目结构里还有一个容易忽略的点:配置。如果项目只有一两个配置项,你直接在config.py里写常量就够了:

API_BASE_URL = "https://api.example.com" DEFAULT_TIMEOUT = 30

但当配置项变多,特别是不同环境(开发、测试、生产)下取值不一样时,写在代码里就是灾难。常见做法是用 YAML、TOML 或环境变量来管理配置。

我在小项目中常用的做法是:建一个configs/目录,里面有config.yamlconfig.test.yaml等文件,然后在代码里写一个加载函数。这里关键是“默认配置路径”不要写死,让用户可以通过命令行参数或环境变量指定:

# src/my_project/config.py import os from pathlib import Path import yaml DEFAULT_CONFIG_PATH = Path("configs/config.yaml") def load_config(path: str | None = None) -> dict: config_path = Path(path or os.getenv("APP_CONFIG", DEFAULT_CONFIG_PATH)) with config_path.open("r", encoding="utf-8") as f: return yaml.safe_load(f)

在 CLI 入口里再暴露--config参数,这样无论从哪里启动,使用的人都清楚知道“这个项目的配置入口在哪”。

如果涉及密钥和敏感信息,一定不要提交到 Git 仓库。你应该把它们放到环境变量里,或者放到本地.env文件(配合python-dotenv使用),并且把.env写进.gitignore。这一步既是安全要求,也是结构管理的一部分,因为敏感配置一旦进了代码库,无论之后怎么删,都会留在 Git 历史里。

6. Python 项目结构避坑指南与常见排查技巧

6.1 最容易被项目结构坑到的瞬间

做项目结构这件事,你很少会因为结构“正确”而获得即时反馈,但一定会因为结构“错误”而付出代价。我把踩过的坑按出现频率排个序:

第一个坑是把自己写的项目当成“环境变量里的可执行文件”来跑,结果各种找不到模块。很多新手写完一个项目,直接在 IDE 里右键运行src/my_project/cli.py,结果from my_project.config import Config报错。因为此时 Python 把“src/my_project”目录加入搜索路径,而my_project这个包名需要从“src”目录开始才能导入。

解决办法有两个:最省事的,在 IDE 里把工作目录设置为项目根目录,并且用模块方式运行:

python -m my_project.cli

但更彻底的办法是利用我们前面说的 editable install,先在项目根目录执行:

pip install -e .

安装以后 Python 环境中就有了my_project这个包,无论在哪个目录下启动 Python,都能import my_project。自此之后你就不用靠“运气”来跑代码了。

第二个坑是项目结构定了,但谁都记不住该在哪放代码。这就不只是技术问题了,需要团队约定或 README 里写明结构。我一般会在 README 里放一个“代码组织”小章节,几句话说清楚每个目录放什么。如果没人看 README,那就在__init__.py或模块 docstring 里写清楚职责。

第三个坑是 Python 缓存和旧字节码导致的“改了 code 没生效”错觉。当你重构项目时,如果移动过模块,老的__pycache__可能还在,Python 有时会命中旧的缓存文件。虽然正常情况下 Python 会根据源文件时间戳判断缓存是否过期,但你手动移动文件夹时确实可能遇到诡异情况。遇到此类问题,优先清理所有__pycache__

find . -type d -name "__pycache__" -exec rm -rf {} +

再重新运行,往往就能恢复。

6.2 循环导入与相对导入问题处理

在包内部互相导入时,另一个高频地雷是循环导入。比如module_a.pyfrom .module_b import func_b,而module_b.pyfrom .module_a import func_a。当程序真正执行到导入的瞬间,两个模块都还没创建完,于是 Python 抛出ImportError: cannot import name ...

避免循环导入的方法不是“调整 import 顺序”,而是从结构上消除循环依赖。常见策略有三种:

  • 把公共函数下沉到一个新的模块,比如common.py,让两个模块都只依赖common,而不再互相依赖。
  • 把其中一个依赖改为延迟导入,在函数体内导入,而不是在模块顶层导入。
  • 把类或函数作为参数传入,而不是在模块内部直接引用对方。

第三种方式在设计上更优雅,但也需要调用方做出配合。我在实际项目中遵循一个原则:顶层模块之间尽量不互相导入,依赖关系应该形成有向无环图,而不是一个环。如果发现某个模块既需要 A 又需要 B,而 A 又反向依赖了它,说明职责划分有问题,需要继续拆分。

还有一类问题是相对导入和绝对导入混用。在包内部,from .module import something是相对导入,.module是相对于当前模块所在的包。在包内部使用相对导入,可以避免顶层包重名时出现的混乱。但如果在入口文件cli.py里直接用相对导入,然后你把cli.py当作脚本直接执行(python cli.py),Python 会报ImportError: attempted relative import with no known parent package。因为脚本运行时,Python 不认为它属于任何包。

所以我的习惯是:包内部各个模块之间用相对导入,包外入口只用绝对导入。比如cli.py入口文件用from my_project.config import Config,而config.py内部想导入同包的utils,则用from .utils import helper

6.3 常见问题速查表:一次解决“跑不起来”的尴尬

我把我这些年经常遇到的、和项目结构强相关的报错信息整理成一个速查表,大家可以当做一个 checklist:

报错场景常见原因推荐解法
No module named 'my_project'包未安装或当前目录不在搜索路径中在项目根目录执行pip install -e .,然后用模块方式运行
运行时FileNotFoundError代码中使用了相对路径,依赖当前工作目录改用Path(__file__).resolve()定位项目根目录,拼接绝对路径
从脚本直接运行报相对导入错误脚本被当作顶层模块运行,不知道父包是谁入口脚本使用绝对导入,或用python -m pkg.xxx方式执行
改代码后运行结果不变可能命中旧的__pycache__缓存清理__pycache__目录后重试
安装时报packages没找到setuptools 不知道包位置pyproject.toml配置[tool.setuptools.packages.find] where = ["src"]
测试里 import 不到被测模块测试目录没有安装项目安装开发模式pip install -e ".[dev]",测试内部统一用绝对导入
执行不同目录的脚本导入策略不一致脚本期望的工作目录不同把所有脚本改成基于项目根目录定位,并提供统一--config入口

可能大多数人在刚开始看这张表的时候还会觉得有些条目抽象。不要紧,你只需要记住一条核心思路:让项目根目录成为所有路径、导入、配置的相对基准,并用“安装”而不是“运气”来让 Python 认识你的包。把你写的代码当成一个需要被安装、被调用的正经包,你的很多路径和导入问题会同时消失。

6.4 别照抄别人的结构:先考虑可测试性与扩展点

网上能看到很多开源项目的目录结构,比如教育类项目、爬虫框架、算法库等。你可以参考,但千万不要照抄。每个项目的“变与不变”不一样,抄来的结构可能不适合你,反而成为负担。

举个例子,如果你想开发的是一个类库被其他人使用,目录结构里src布局、类型注解、文档生成就是重点。如果你开发的是给老板看的报表生成脚本,那么一个干净的入口加可视化输出可能比规范的包结构更重要。如果你做的是一套算法实验框架,那配置和实验追踪的目录设计可能比代码本身还要关键。

我的建议是:在动手写核心代码前,先花半小时画一画模块视图。不用画很精细的 UML 图,就写清楚:从哪个入口开始执行,它会依赖哪些模块,每个模块依赖哪些数据,数据从哪里来,结果输出到哪里。画完这张图,你的目录结构大概就出来了。

同时,注意可测试性。如果某个模块被设计成导入时立刻联网、立刻读数据库,那这个模块就很难被测试。更合理的做法是将“副作用”操作放到cli.pymain()里,核心模块保持输入输出简单,这样测试只需构造输入然后断言输出。这其实不是项目结构的问题,而是代码组织意识的问题,但项目结构能大幅放大或限制这种意识。你问一个 Python 写了很多年的同学,他最庆幸的事是什么,往往不是某天写出了一个多么漂亮的算法,而是项目从一开始就被组织得恰到好处——当他需要给代码加测试、加新功能、换数据源时,都不用对整个项目动大手术。

7. 这类项目结构的扩展方向:从单包到多包与工作区

7.1 什么时候需要把代码拆成多个包

我前文讲的都是“单包项目”的组织方式。随着代码量上涨,你会发现一个包内塞的东西越来越多。当出现以下信号时,你可能需要考虑拆包:

  • src/my_project/下面光一级子目录就有十几个,且互相依赖很弱。
  • 项目的一部分将来要单独复用,比如一个数据清洗库,既被当前项目用,也被未来新项目用。
  • 项目包含多个可独立部署的组件,比如一个 API 服务加一个定时任务处理器,它们除了共享模型和工具函数之外没什么关联。
  • 不同部分的依赖差异很大,比如一个组件依赖pandas,另一个组件只依赖fastapi。如果全放一起,用户安装了 API 服务就不得不背上数据科学的重量级依赖。

这种情况下,仓库内可以演化出多个包结构。如果只是同一个仓库里既有算法库又有 Web 接口,可以考虑保留单仓库但把两个组件分别放到src/algo_lib/src/api_service/两个顶层包中,中间通过一个共享包进行通信。

如果你管理的是更大规模的代码库,比如一个组织里有多个服务共享公共常量和数据库模型,可以考虑把公共代码单独建一个包。但拆包不是越细越好,包的粒度太碎会导致版本发布和依赖管理变得很麻烦。拆包前先问一下自己:这个模块真的会被其他项目直接 import 吗?还是说只是当前位置不太好看而已?如果只是目录不好看,那就留在当前包里继续重构目录组织,而不是急着拆出去。

7.2 多包工作区的管理策略

在我个人的实践里,如果有一个仓库需要同时开发多个包,我会特别关注两个工具:editable install 和统一测试入口。

假设仓库结构是:

workspace/ ├── src/ │ ├── shared_lib/ │ ├── service_a/ │ └── service_b/ ├── tests/ │ ├── test_shared_lib/ │ ├── test_service_a/ │ └── test_service_b/ ├── pyproject.toml

这时候我通常不建一个“包住一切”的 pyproject,而是在每个包目录下单独维护 pyproject.toml,根目录的 pyproject 只放统一工具链配置,比如 ruff、pytest、mypy 的公共配置。开发时先把 shared_lib 以 editable 方式安装到虚拟环境,再安装 service_a 和 service_b,这样所有包都在同一个虚拟环境中可用,测试也能在根目录统一跑。

如果包之间必须独立发布,可以给每个包单独维护版本号。这是一个很大的话题,但基础的目录组织逻辑不会变:仓库被拆成多个独立包,每个包有自己的核心代码、测试和打包配置,共享的开发和发布规范尽量写在根配置中,避免每个包都维护一份重复内容。

这些都是从单包结构自然延伸的。先把当前项目做好,别在一开始就设计一个宇宙级工程。

8. 最后再分享一点个人体会

写了这么多年 Python,我自己最深的感受是:项目结构不是“能不能跑起来”的问题,而是“你还能不能继续改下去”的问题。一个结构混乱的项目,前期代码涨得飞快,越到后期越发寸步难行;一个结构清晰的项目,前期可能要多花几十分钟搭目录、写 pyproject、建测试目录,但后期每一次改动的成本都被压得很低。

所以如果你现在正被自己的代码坑得头疼,不妨停下来,花一个下午把项目结构重新梳理一遍。你不一定需要一步到位用 src 布局,也不一定马上把所有配置迁到 pyproject.toml,你只需要遵守三条简单原则:第一,核心代码和入口脚本分离;第二,配置文件不写死在代码里,路径不依赖当前工作目录;第三,从第一天就建立 tests 目录和 .gitignore。这三件事做好,你的项目就已经超过相当一部分同行了。接下来再慢慢迭代,向着更规范的结构演进,回头你会发现,这半天时间花得实在太值了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询