
你决定要写一个新Python项目于是打开终端敲下mkdir myproject然后是touch main.py。三天后你的项目变成了一个堆满杂乱脚本、无法复现环境、甚至无法导入自己模块的沼泽。这几乎是我们每个人的起点。问题的根源并非代码能力而在于你从未把“搭建骨架”这件事当成设计来对待。这一篇我们从零开始把目录结构和依赖管理掰开揉碎讲透它背后的原则与现实取舍。目录结构先决定你项目的“质数根”一个干净的目录结构不是文件放得整齐那么简单。它是在为未来的维护者很可能就是三个月后的你减少认知负担。想象一下当所有代码都挤在根目录pip install之后你根本无法判断哪些是项目代码、哪些是配置文件、哪些是测试。所以第一步永远是把“可执行代码”和“周边文件”分离。最保守、最稳妥的布局是这样的your_project/ ├── src/ # 源码根避免直接使用项目名做顶层包 │ └── your_package/ │ ├── __init__.py │ ├── core.py │ └── ... ├── tests/ ├── docs/ ├── scripts/ ├── pyproject.toml ├── README.md └── .gitignore注意这里使用了src布局而不是把your_package直接放在项目根目录。这看起来多了一层但实际上它根治了“当前目录导入陷阱”——当你直接在项目根运行Python时你能导入根目录下的任何模块但那完全依赖你恰好站在这个目录里。一旦你的代码被安装成依赖或者你从子目录执行脚本这种“隐式导入”就会崩坏。src布局强制你通过安装包来使用代码你的代码行为在任何地方都一致。这就是为什么成熟的 Python 库几乎都采用src布局的原因。包内组织模块不是文件夹的堆砌很多人以为__init__.py只是历史遗留的标记空着就行。大错特错。__init__.py是包的门面是公共API的边界。你在里面显式写出from .core import function_x就是在告诉使用者“这才是你该导入的东西”。相反如果你习惯写from your_package.core import function_x那么一旦你重命名了核心模块所有下游代码都会碎掉。把__init__.py当作防火墙内部模块可以自由重构只要门面不变你的包就是稳定的。对于核心业务逻辑建议按“责任”拆分而不是按“技术类型”拆分。不要搞utils.py、helpers.py这种垃圾桶模块。垃圾桶模块会逐渐成为吞噬一切的熵增黑洞。更好的做法是一个模块负责一件事比如httpx_client.py只负责HTTP通信models.py只放数据模型。哪怕一开始每个模块只有几十行也不要为了“少文件”而把不相关代码塞在一起。写代码时你可能会觉得多文件麻烦但重构的爽快感永远属于那些拆分清晰的模块——你可以直接扔一个文件而不是从一千行里捞出一个函数。配置文件把“内容”与“代码”分离配置是另一个高频翻车点。许多人把 API 密钥直接写进settings.py然后 commit 到 Git 仓库。这不仅是安全灾难更是协作灾难。配置的本质是环境变量而不是代码。正确的方式是应用代码读取环境变量而你把真实值放在.env文件并加入.gitignore或 CI 平台的 Secret 中。版本库里只提交.env.example里面是键名和模拟值。如果你想更进一步可以使用pydantic-settings来构建配置层。它能在启动时校验类型、提供默认值并自动读取环境变量。这样做的好处是你的配置成为唯一可信源任何缺失或错误类型都会在最早期被捕获而不是等到调用某个服务时才炸开。记住配置不是“写进去把它忘掉”配置是项目运行前最后一道防线。依赖管理从requirements.txt到pyproject.toml依赖管理是整个项目搭建中最容易被低估的部分。一个项目开始时的依赖是requests和numpy看起来人畜无害。但半年后你发现维护困难、构建失败、测试环境不一致——因为你的依赖清单还是那份手写的requirements.txt。先破除一个迷信requirements.txt本身没有问题问题是它被用错了场景。requirements.txt适合锁定一个精确的运行环境例如生产部署的“快照”它不适合描述项目的元信息、依赖范围、构建工具和入口点。现代 Python 项目的元信息应该统一放在pyproject.toml中。这个文件是 PEP 518/621 的标准它告诉工具链你的包叫什么、版本多少、需要哪些依赖、可选依赖有哪些、命令行入口在哪。requirements.txt可以作为pyproject.toml的“锁定文件”保留但它的内容应该是从锁定的环境中导出的完全固定的版本号而不是手写的“宽松约束”。虚拟环境隔离是纪律不是选择如果你的项目只有一个全局 Python 环境那你最终会在处理不同项目依赖冲突时崩溃。虚拟环境不是可选的优化而是每一个项目的基础设施。工具的选择上venv是 Python 内置的永远有效conda适合数据科学场景poetry和uv提供了更优雅的体验。但关键问题只有一个你能否做到“每个项目一个环境”并且让环境创建可重复如果不能你很快就会陷入“在我机器上跑得好好的”的泥沼。如果你使用uv它的速度极快并且能直接用uv lock生成跨平台的锁定文件。如果你使用poetry它的依赖解析器会非常严格地为你选出兼容的组合并生成poetry.lock。严格依赖解析的价值在于它把不确定性发生的时刻从深夜部署时提前到开发初期的几分钟。你不必记住哪些包会冲突解析器会帮你处理。不过无论选择哪款工具都不要把虚拟环境目录如.venv提交到 Git。这听起来像废话但无数仓库里都躺着.venv的残骸。你需要在.gitignore中加入至少以下内容.venv/ __pycache__/ .pyc .pytest_cache/ .env dist/ build/ .egg-info/这样做的意义不仅是防止污染更是让任何克隆仓库的人都能从头重建环境。而“从头重建”恰恰是检验项目的试金石如果新成员无法在十分钟内从git clone走到运行测试那么你的项目搭建就是失败的。依赖类型别把所有东西混在一口锅一个科学合理的依赖声明至少应分为三类。运行依赖代码 import 时需要的包比如requests。开发依赖测试、文档、格式化、lint 工具比如pytest、ruff、sphinx。可选依赖特定插件或额外特性需要的包比如pandas[test]。为什么必须区分因为生产环境不应安装pytest和ruff这会让镜像膨胀、可能引入不必要的安全风险而且安装时间也会拉长。在pyproject.toml中我们使用[project.optional-dependencies]来定义这些分组。[project] dependencies [ requests2.31.0, pydantic2.5.0, ] [project.optional-dependencies] dev [ pytest7.4.0, ruff0.1.0, mypy1.7.0, ]看到没有精确的依赖分类就是让不同环境各取所需的生产力工具。你安装pip install -e .[dev]本地开发环境拥有全部工具部署时你只用pip install .生产环境只保留核心运行库。清晰的分组远比一个把所有东西都包含在内的requirements-deploy.txt更优雅也更容易维护。版本约束的艺术临界大于等于别用“大于等于”依赖版本字符串的写法往往暴露一个工程师的经验深度。新手喜欢写requests2.0因为它看起来安全。但这里有个陷阱允许未来任何大版本更新如果依赖方在不破坏API的前提下发布了一个带bug的版本你的代码就莫名其妙地坏了。反过来写死版本requests2.31.0又过于僵硬会阻碍安全更新和兼容性修复。一个被广泛接受的策略是“兼容性上限”使用给最低版本使用给下一个大版本的边界。例如requests2.31.0,3.0。这被称为“弹性版本约束”。它既告诉解析器你的最低要求又排除了未知的破坏性变更。对于提供SDK的库这个策略尤其重要——你的下游用户会因为你过于宽松的版本范围而陷入装包地狱。版本约束是一种通信语言它传递的是“我敢保证我代码在什么范围内能工作”而不是“我随便挑一个看起来舒服的数字”。锁文件的本质用确定性对抗时间即使有了pyproject.toml的柔性声明你依然需要一把“锁”来锁定最终解析出的确切版本。poetry.lock、uv.lock或者由pip-tools生成的requirements.lock本质上都是一张“经过验证的真实快照”。它依赖于你解析的那一刻的 PyPI 索引。它的作用是让任何人在任何时间至少在相似环境中安装到的依赖都和你测试时完全一致。这是可重现构建的核心。但请注意锁文件并非万能。它只能锁住 Python 包的版本不能锁住操作系统、C 库版本、编译器开关。如果你的依赖中包含带 C 扩展的包如numpy那么同一份锁文件在 Linux 和 macOS 上依然可能解析出不同的 wheel。所以在引入锁文件的同时你需要一个可重复执行的构建命令——例如 Makefile 或 nox 会话——它负责虚拟环境的创建、依赖安装、环境验证。把所有步骤固化成一条命令比任何文档都有效。入口点从“脚本”到“安装包”的蜕变一个项目真正的成熟标志不是它能跑而是它能被“安装”。当你写下[project.scripts]你的项目就从一个可运行脚本变成了一个可再分发、可组合的工具。在pyproject.toml中[project.scripts] yourcli your_package.cli:main这行声明让安装后的系统生成一个yourcli命令直接调用你包中的main()函数。它消除了“python 你的脚本.py”这种粗糙的执行方式让你像使用ls一样使用自己的项目。更重要的是入口点机制让你能构建插件体系其他包可以通过相同的入口点为你的应用添加子命令。如果你正在开发一个库入口点也能让你的测试框架比如 pytest 插件直接识别你的插件。这是把项目从“文件夹中的一堆文件”升级为“软件”的关键一步。测试与文档目录结构的延伸在项目搭建中tests/和docs/往往被当作装饰。但它们是结构的有机部分。测试目录必须镜像源代码的结构因为这样你在定位测试失败时能直接路径对应而不是在一个叫test_something.py里猜它测的是哪个模块。tests/里还需要一个conftest.py它负责共享夹具和初始化环境这比在每个测试文件里重复设置要干净得多。文档方面我推崇“文档即代码”的轻量理念——docs/目录下存放.rst或.md并配合mkdocs或sphinx构建。你可以用tox或nox自动执行“测试 构建文档 lint”的统一流程。这些工具不是额外的负担而是你目录结构中的“把门人”。实战走查一个最小项目从零开始现在让我们做一个真实的决策树。假设你的项目名为crawlerx你希望它最终是一个命令行爬虫工具。你打开终端输入mkdir crawlerx cd crawlerx uv init --package --name crawlerx使用uv init --package会自动生成pyproject.toml、src/crawlerx/__init__.py和一个 README。若用 Poetry则是poetry new --src crawlerx。这一步的核心是确保你的包目录在src/之下。接着编写你的核心模块。在pyproject.toml中声明依赖时给出合理范围。想清楚你的工具到底需要httpx还是requests需要pydantic做配置吗不要一进来就装一个非常庞大的scrapy这种依赖滞后决策会摧毁你的基础设计。然后创建.env.example为配置留出接口。添加一个最小可用的cli.py在其中定义main函数并在[project.scripts]中注册。再写三个测试文件一个测试配置解析一个测核心函数一个测 CLI 端到端。执行uv run pytest或poetry run pytest确认通过。最后提交你的初始版本。这个过程中你每多花一分钟设计目录与依赖都会在未来为你省下十小时的排障时间。没有任何工具包的引入是必须的但有一种纪律是必须的对“自己的项目到底由什么组成”保持清醒。清晰的结构是沟通的起点而锁定的依赖是信任的基础。依赖管理策略的常见误区有人问我现在项目很小需要这么讲究吗答案是越是小项目越要把骨架立正因为小项目会快速成长。如果从一开始你就放任依赖混装、目录扁平化那么等项目到了三千行代码时你已经不想再重构了你会开始憎恨这个项目。这种内耗是真实存在的。还有一个常见误区是追求“零依赖”。零依赖确实让安装极为简单但代价是大量重复造轮子。合理的依赖是杠杆是把精力留给真正的业务逻辑。当然你也应该审视每一个新增依赖的“破坏半径”——它会不会在你的项目中拖入一堆间接依赖用pipdeptree检查依赖树看看哪些包在反复移植同一套urllib3。依赖越多熵越高因此每一次增加依赖都应当像引入一位正式员工一样谨慎。另一个误区是把依赖安装全部交给pip install -r requirements.txt。这条命令本身没什么但一旦你开始添加--upgrade或者在不同机器上运行未锁定的requirements.txt你就在主动拥抱随机性。可重现的项目绝不会依赖“恰好安装到什么版本”来工作。所以如果你的团队还没有共识请你主动引入uv或poetry并在 README 中写下“安装本项目uv sync”。构建你的“项目宪法”每一个高质量的 Python 项目都像一部宪法目录结构是它的分权与制衡依赖管理是它的预算与税法入口点是它的政府职能。一个松散的项目总是从一次小妥协开始——今天忽略__init__.py的门面设计明天跳过虚拟环境后天把第三方依赖直接pip install在系统 Python 里。这些都不是技术问题而是组织问题。你需要建立自己的“项目宪法”任何代码必须能通过安装来被使用而不是依靠相对路径。依赖必须分环境声明并有一把锁。配置必须来自环境变量备份进.env.example。测试必须能独立运行不依赖工作目录。文档必须与代码同源同一次 CI 验证。当这些原则成为你每次新建项目的默认动作你就不再是在“搭目录”而是在构建一个可以持续演进的生命体。那个最初的mkdir与touch从此有了意义。最后给你一个最直接的行动建议忘掉你对项目“之后会整理”的幻想。一个无法在开局就维护清晰结构的项目永远不会有“之后”。从此刻在你新建的空目录里写下pyproject.toml架好src/定义你的依赖范围然后提交这一次干净利落的初始提交。你会感谢这个决策的。