数据STUDIO

人人都是架构师:2026年如何规划 Python项目

Image

我以前开一个新的 Python 项目,经常会先做一件现在看起来很浪费时间的事:

重新想一遍“这次目录到底怎么搭”。

先建个 main.py,跑起来再说。过两天多了几个函数,顺手塞进 utils.py;配置开始变多,再补一个 config.py;测试一开始懒得建目录,后来发现已经不知道该从哪里补。等项目真的开始长,helpers.py、utils.py、common.py 三个文件往往已经长得谁也说不清边界。

Image

最麻烦的还不是“丑”。

而是下一次开新项目,你又会把同样的问题重新想一遍:

  • 虚拟环境这次用什么?
  • 依赖写 requirements.txt 还是放 pyproject.toml?
  • 开发依赖放哪?
  • 包是平铺还是 src/?
  • 格式化、lint、类型检查、测试分别怎么跑?
  • 半年后换台机器,能不能一条命令把环境还原出来?

这些问题当然都能研究,而且每一个都能争很久。

但绝大多数项目真正需要的,根本不是一套“最聪明”的结构,而是一套不用每次重新决定的结构。

到 2026 年,Python 生态里已经有一组工具可以比较自然地拼在一起:

  • pyproject.toml:放项目元数据、依赖声明和大部分工具配置;
  • uv:管 Python 版本、环境、依赖、锁文件和命令执行;
  • src/ 布局:给可安装项目一个更干净的 import / 打包边界;
  • Ruff:lint + format;
  • mypy:静态类型检查;
  • pytest:测试;
  • 再加一个很薄的命令入口,例如 Makefile,把日常命令收口。

这篇文章不打算证明“所有 Python 项目都必须长这样”。

如果你只是写一个几十行的一次性脚本,完全没必要为了工程感硬上 src/、打包系统和一整套质量工具。

我想给的是另一种场景的默认答案:

这个项目准备认真写一阵子,会有测试,会继续迭代,可能交给别人,也希望三个月后自己回来还能接着干。

这种项目,结构最好从第一天就少一点即兴发挥。

01先看结果:我更愿意从这套外层骨架开始

先别急着建 models/、services/、utils/。

新项目一开始,真正值得固定的是外层边界,不是提前猜未来会长出几层业务目录。

myproject/
├── .python-version
├── src/
│   └── myproject/
│       ├── __init__.py
│       ├── cli.py
│       └── config.py
├── tests/
│   └── test_cli.py
├── pyproject.toml
├── uv.lock
├── Makefile
├── README.md
├── .gitignore
└── .pre-commit-config.yaml
Image

本地还会有一个 .venv/,但它通常不进 Git。

这套结构先把几件最容易混在一起的东西分开:

  • 项目配置在根目录;
  • 真正可安装的 Python 包在 src/;
  • 测试单独放;
  • 环境由 lockfile 重建,而不是靠“我电脑里现在装了什么”;
  • 日常质量命令有固定入口。

至于包里面以后要不要长出 models/、services/、clients/、repositories/,等代码真的出现这些职责以后再拆。

骨架负责减少决策,不负责提前替未来做架构。

02pyproject.toml:把“这个项目是什么、依赖什么、工具怎么配”放到一个地方

Python 项目以前最让人头大的画面之一,就是根目录里一排配置文件:

setup.py、setup.cfg、requirements.txt、tox.ini、各种工具自己的配置……

Image

今天更自然的做法,是让 pyproject.toml 成为项目配置的主要入口。

这里有个容易说错的历史细节:pyproject.toml 不是 PEP 621 凭空发明出来的。PEP 518 先定义了这个文件及构建系统入口,后来 PEP 621 标准化了 [project] 元数据;开发依赖这类分组,现在又有标准化的 dependency groups。

你不需要背 PEP 编号。

真正要记的是:项目元数据、运行依赖、开发依赖,以及 Ruff / mypy / pytest 这类工具配置,现在可以比较干净地集中在一个文件里。

一个够用的片段可以长这样:

[project]
name = "myproject"
version = "0.1.0"
description = "A short, honest description."
requires-python = ">=3.12"
dependencies = [
    "httpx>=0.27",
    "pydantic>=2.6",
    "pydantic-settings>=2.2",
]

[dependency-groups]
dev = [
    "pytest",
    "ruff",
    "mypy",
    "pre-commit",
]

[tool.ruff]
line-length = 88
target-version = "py312"

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM"]

[tool.mypy]
python_version = "3.12"
strict = true

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"

这里特意把开发依赖写成了:

[dependency-groups]
dev = [...]

而不是:

[project.optional-dependencies]
dev = [...]

这两个东西看起来很像,但语义不一样。

[project.optional-dependencies] 更接近发布给使用者选择安装的 extras,例如:

[project.optional-dependencies]
postgres = ["psycopg[binary]"]

而 lint、测试、类型检查这类只服务开发过程的依赖,更适合放在 [dependency-groups]。uv add --dev ... 走的就是这条路径,而且 dev 组默认会跟项目一起同步。

另外,如果你是用 uv init --package 创建项目,构建系统配置会由 uv 初始化出来。这部分保留生成结果即可,没必要照着文章手抄一个可能很快过时的 build-backend 版本号。

配置集中真正舒服的地方,要等到两个月以后才会感觉到。

你想改 Ruff 规则,不用猜 .flake8 在哪;想看 Python 版本和依赖声明,不用翻三个文件;CI 里要跑测试,也知道项目配置从哪里读。

少一次“这个到底写在哪”的考古,都是赚的。

03uv:别再把环境管理拆成一串仪式

pyproject.toml 把规则收拢了,uv 做的事情则更实际:把日常环境操作压短。

Image

以前一个新项目常见的流程是:

建虚拟环境 → 激活 → pip install → 维护 requirements → 再想办法锁版本 → 换机器后重新还原。

这些步骤单独看都不难,但它们拼在一起以后,最烦的是“每个项目都可能略有不同”。

现在可以直接:

uv init --package myproject
cd myproject

uv add httpx pydantic pydantic-settings
uv add --dev pytest ruff mypy pre-commit

跑到这里,pyproject.toml 会被更新,依赖会被解析,项目环境和 uv.lock 也会进入工作流。

有一个细节值得讲清楚:uv init 本身主要负责初始化项目;.venv 和 uv.lock 会在后续 uv add、uv sync、uv run、uv lock 这类项目操作中创建或更新。

这听起来只是措辞差别,但教程里最好别把“初始化项目”和“已经把环境同步完”混成一件事。

uv 真正省心的地方,不只是快。

而是你以后可以把大多数项目命令都统一成:

uv run ...

比如:

uv run pytest
uv run ruff check .
uv run mypy src/

团队里的人不需要先问“你虚拟环境激活了吗”,也不用关心工具到底装到系统 Python 还是项目环境里。

工具退到背景里,才是工程体验真正变好的地方。

04src/ 布局:它解决的是 import 边界,不是“看起来更专业”

src/ 这些年经常被写成一种“现代 Python 项目必须这么摆”的仪式感。

其实没必要。

如果你就是一个简单脚本、小工具,甚至不打算把它当成可安装包,平铺完全可以。uv 自己也支持不使用构建系统的简单项目。

但如果你的代码会作为一个包被安装、被测试、被其他模块引用,src/ 布局就很值。

对比一下:

# 平铺
myproject/
├── myproject/
├── tests/
└── pyproject.toml
# src 布局
myproject/
├── src/
│   └── myproject/
├── tests/
└── pyproject.toml

它解决的核心问题不是“目录更整齐”,而是:

别让项目根目录天然成为你包代码的捷径。

在平铺布局里,从项目根目录启动 Python 时,本地包目录很容易直接出现在 import 路径上。这样某些导入问题可能被开发环境悄悄掩盖。

src/ 把包移出根目录,迫使项目通过安装语义进入环境。使用 uv 的 packaged project 时,项目同步后默认会以 editable 方式安装,所以你改源码仍然能立刻生效,但 import 边界会清楚得多。

这里也别说过头:

src/ 能减少“我测的是根目录源码捷径”这一类问题,但它不等于“测试的就是最终发布 wheel 的字节级同一份东西”。

如果你真的在做发布包,CI 里再加一次 build + 安装构建产物的测试,会更严谨。

对大多数日常项目来说,先把 import 边界守住,已经很值了。

05包内部怎么分:先按职责长,不要一上来搭三层架构

很多“项目结构教程”最容易把人带进另一个坑:

外层刚整理干净,马上又给你建:

models/
services/
repositories/
utils/
schemas/
core/
common/

然后项目里总共只有 800 行代码。

这不是工程化,这是提前装修一栋还没盖起来的楼。

我的建议更简单:

只有当一类职责真的出现第二、第三个文件时,再考虑给它一个目录。

项目刚开始,完全可以只有:

src/myproject/
├── __init__.py
├── cli.py
├── config.py
└── client.py

后面如果数据模型真的变多,再出现 models/;外部服务客户端变多,再出现 clients/;一组业务行为真的形成稳定边界,再拆对应模块。

utils/:能不建就先不建

utils.py 最大的问题不是名字不好,而是它太方便。

一个函数暂时不知道放哪,丢进去。

再来一个,也丢进去。

半年以后打开文件,里面有日期处理、HTTP 重试、字符串清洗、hash、路径拼接、业务判断——它已经不是工具箱,而是架构债务仓库。

一个很好用的判断是:

如果一个 helper 只服务某个业务概念,就把它留在那个概念旁边。

只有真的跨领域、没有业务归属的通用小工具,才有资格进入 utils。

config.py:配置集中,但别把秘密写进代码

配置倒是值得早一点固定边界。

例如使用 pydantic-settings:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    database_url: str
    api_key: str
    debug: bool = False

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )


settings = Settings()

这样环境变量的读取、默认值和类型校验至少集中在一个地方,而不是每个模块各写一遍 os.getenv()。

本地开发用 .env 没问题,但 .env 本身应该进 .gitignore,别把真实密钥跟着仓库一起提交。

配置最好无聊。

打开 config.py 能马上知道有哪些配置、从哪来、缺了会不会报错,这就够了。

06Ruff + mypy + pytest:质量工具别堆,固定一套能每天跑的

代码质量工具最怕两件事:

一是装太多。

二是装完没人跑。

Image

Ruff 的价值就在于它把很多过去分散的动作收到了一个工具里:lint、import 排序、代码现代化检查,以及 formatter。

最常用的两条命令基本就够:

uv run ruff check .
uv run ruff format .

Ruff 官方当前的 pre-commit 集成里,lint hook 使用 ruff-check,formatter 使用 ruff-format。如果 lint 开 --fix,顺序应该是先 lint fix,再 format:

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.2
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format

注意这里的 rev 是当前文档示例版本,不是什么永恒答案。以后更新项目时,正常升级即可。

mypy 也一样。

新项目从 strict = true 开始,我个人很喜欢,因为早期类型债务最便宜。但它不是一条“所有正经项目必须 strict”的行业戒律。

如果你的项目重度依赖类型信息不完整的第三方库,或者正在迁移老代码,完全可以先从关键模块开始,再逐步收紧。

重点不是拿到一个“最严格”的徽章。

而是让类型检查真的长期处于可运行状态。

测试这边,pytest 依然很适合作为默认选择:

uv run pytest

如果源码开始分模块,测试目录大致镜像源码职责,会比较好找:

src/myproject/client.py
tests/test_client.py

但不用为了镜像而机械地复制每一层目录。测试结构最终还是服务于“出问题时能快速定位”。

至于 mock,也别走极端。

外部 HTTP、时间、随机性这类边界当然该隔离;但如果一个测试把数据库、文件系统、网络、业务对象全部 mock 掉,最后很可能只证明“这些 mock 会按你写的剧本演”。

关键链路留一点真实集成测试,通常更值钱。

07pre-commit:适合挡住低级问题,不要把整个 CI 塞进去

pre-commit 很适合做一件事:

把足够快、足够确定的检查提前到 commit 前。

例如 Ruff fix + format。

安装时,如果 pre-commit 本来就是项目 dev dependency,直接:

uv run pre-commit install

就行。

这里也有个常见误区:别因为有了 pre-commit,就把完整测试套件、重型类型检查、慢集成测试全塞进去。

commit 变成一分钟一次以后,人会开始绕过 hook。

本地 hook 负责快速反馈,完整 mypy / pytest / 集成测试交给统一检查命令和 CI,边界反而更清楚。

08Makefile:不是必须,但“统一入口”很值

工具已经不少了。

如果新人进仓库以后还要背:

ruff 到底怎么跑?
mypy 检查哪个目录?
pytest 有没有额外参数?
format 和 lint 谁先?

那只是把配置问题换成了命令问题。

我喜欢留一个很薄的 Makefile:

.PHONY: fmt fix format-check lint typecheck test check

fmt:
  uv run ruff format .

fix:
  uv run ruff check . --fix
  uv run ruff format .

format-check:
  uv run ruff format --check .

lint:
  uv run ruff check .

typecheck:
  uv run mypy src/

test:
  uv run pytest

check: format-check lint typecheck test

这里我不会把 fmt 塞进 check。

因为“检查”最好是只检查,不应该偷偷改文件。真正需要自动修复时,明确跑:

make fix

提交前或 CI 跑:

make check

职责很清楚。

当然,Makefile 不是 Python 标准,也不是跨平台唯一答案。团队已经有 just、任务脚本、CI wrapper,就继续用。

重点从来不是 Make。

重点是:日常操作要有一个稳定入口。

09十分钟搭完:一套更自洽的 bootstrap

把前面拼起来,一条初始化流程可以写成:

uv init --package myproject
cd myproject

uv add httpx pydantic pydantic-settings
uv add --dev pytest ruff mypy pre-commit

mkdir -p tests
touch tests/test_smoke.py

uv run pre-commit install

uv init --package 已经会为 packaged project 建好 src/<package>/ 这层基本结构,不需要再手动重复造一遍。

接下来再补:

  • pyproject.toml 里的 Ruff / mypy / pytest 配置;
  • .pre-commit-config.yaml;
  • Makefile;
  • .gitignore 里的 .env、缓存和本地环境规则。

然后跑一次:

make check

如果它是绿的,这个项目至少已经有了一套能复现、能检查、能继续长的起点。

这比“目录看起来专业”重要得多。

10什么先别加:结构最容易死在“为以后准备”

结构好不好,一半看你放了什么,另一半看你忍住没放什么。

1. 别提前建一整套业务分层

项目还没出现稳定职责,就先建 services/、repositories/、domain/、core/,通常只会让每个新文件多一道“到底该塞哪”的选择题。

让结构跟着代码长。

不要让代码迁就一张预先画好的架构图。

2. 别把 __init__.py 变成总出口

适度 re-export 没问题,但如果每个包都在 __init__.py 里把内部所有东西重新导一遍,很快会出现难追踪的导入链和循环依赖。

内部代码大多数时候显式 import 更省脑子。

3. 别建 constants.py 垃圾场

一个常量只有一个模块用,就放在那个模块附近。

真正跨模块、语义稳定的常量,再抽出来。

4. 别把目录叠到看路径都累

如果一个文件要走:

src/myproject/services/external/providers/aws/s3/upload.py

才能找到,先别急着夸自己分层清晰。

问一句:这些层真的都表达了稳定边界,还是只是把一个概念拆得太碎?

5. 别把“应该跑”当成自动化

“提交前记得跑 Ruff。”

“合并前记得跑测试。”

这种话基本等于没说。

能进 pre-commit 的快检查放进去,完整检查放 make check 和 CI。

机制永远比提醒可靠。

11几个最容易踩的坑

坑 1:把 optional dependencies 当 dev dependencies

[project.optional-dependencies] 是 extras;uv 的开发依赖默认走 [dependency-groups]。

两者不要只因为都能装包,就混成一个概念。

坑 2:为了“现代”强上 src/

src/ 对 packaged project、库、需要稳定 import 边界的项目很有价值。

一次性脚本没必要为了目录美学多绕一层。

坑 3:utils 从第一天就存在

如果项目刚建好就有一个空的 utils/,它往往会像黑洞一样吸东西。

等真正出现通用工具再建。

坑 4:一上来就把 strict 类型检查开到项目完全跑不动

新项目可以从 strict 开始;老项目或第三方类型质量一般的项目,逐步收紧更现实。

类型检查的目标是长期在线,不是第一天把团队劝退。

坑 5:把 format 当成 check

CI 里的检查最好不修改代码。

ruff format --check 和 ruff check 用来判定;真正修复交给 make fix。

坑 6:文档写了一套,命令实际又是另一套

README 说 pytest,CI 跑另一组参数,本地 Makefile 又第三套。

久了以后,谁都不知道哪个才是真的。

这也是为什么我更喜欢把入口收成 uv run ... + 一层薄任务命令。

12写在最后

好的骨架,最后应该让你忘掉骨架

我现在越来越不喜欢那种“Python 项目结构大全”。

不是因为结构不重要。

恰恰相反,是因为结构太重要了,所以它不应该每次都变成一个需要重新研究的课题。

一个新项目真正值得你花脑子的地方,应该是:

业务怎么拆,数据怎么流,边界怎么守,错误怎么处理,测试什么才有价值。

而不是开工第一小时还在想:

“这次到底用 requirements.txt 还是 pyproject.toml?”

“虚拟环境叫什么?”

“测试放哪?”

“Ruff、Black、isort 到底怎么拼?”

这些事情一旦有一套稳定默认值,就应该尽快退到背景里。

对我来说,pyproject.toml + uv + src/ + Ruff + mypy + pytest 的价值就在这里。

不是因为这几个名字组合起来显得新。

而是它们终于能把一批过去反复消耗注意力的小决定,变成一次决定、以后复用。

当然,别把这篇文章再变成另一套教条。

如果你只是写一个脚本,就写脚本。

如果项目不需要打包,就别为了 src/ 而 src/。

如果团队已经有稳定工具链,也没必要为了追新全部推翻。

默认答案的意义,本来就不是禁止例外,而是让大多数时候不用从零开始。

真正检验这套骨架有没有用,我觉得不是你刚创建项目时目录有多漂亮。

而是三个月后,甚至半年后,你重新打开这个仓库。

不用先考古环境,不用猜命令,不用回忆“当时为什么这么放”。

跑一句:

uv sync

再跑:

make check

然后开始改代码。

如果能做到这一点,这套结构就已经完成任务了。

好的项目骨架,不是让你一直注意到它。

而是让你尽快忘掉它,把注意力还给真正要交付的东西。

Image
参考资料
  • uv 官方文档:https://docs.astral.sh/uv/
  • Ruff 官方文档:https://docs.astral.sh/ruff/
  • mypy 官方文档:https://mypy.readthedocs.io/
  • pytest 官方文档:https://docs.pytest.org/
  • Python Packaging User Guide:https://packaging.python.org/

Image