Python:项目从脚本到工程,虚拟环境、依赖管理、包结构怎么组织

Python 从脚本走向工程,最容易出现的错觉是:只要目录多几个文件夹,项目就算工程化了。

实际不是这样。真正决定一个 Python 项目能不能继续维护的,通常是下面这些基础问题:

  • 解释器和依赖有没有跟项目绑定
  • 入口是不是清楚
  • 代码职责有没有开始分层
  • 公共逻辑有没有抽出来
  • 新增一个功能时,应该改哪里能不能一眼看出来

如果这些问题没处理,项目很快就会变成:

  • 本地能跑,换机器就不行
  • 代码分了文件,但职责还是混着
  • utils.py 越来越大
  • main.py 什么都管
  • 测试、运行、导出全靠复制粘贴

这一篇直接围绕一个实际小项目展开:做一个巡检报告工具

这个工具要完成这些事:

  1. 读取配置
  2. 调用若干巡检逻辑
  3. 汇总结果
  4. 导出报告

用这个场景把 Python 从脚本走向工程时最关键的四块串起来:

  1. 虚拟环境怎么和项目绑定
  2. 依赖管理怎么记录和复现
  3. 包结构怎么拆,职责才不会乱
  4. 入口文件怎么定,后面才不会到处都是 run_xxx.py

一、这篇文章要解决什么问题

读完这一篇,应该能独立完成这些动作:

  • 给一个 Python 小项目建立独立虚拟环境
  • 记录最小依赖并在新环境复现
  • 把单文件脚本拆成包结构
  • 明确“入口、业务、报告、配置”这些代码应该放哪
  • 识别几类典型坏味道,例如所有逻辑堆在 main.py、公共函数全塞进 utils.py

如果这些动作能独立做出来,Python 项目就不再只是“脚本集合”,而是开始有真正的工程骨架。

二、先看这个项目要长成什么样

先看目标结果,不然工程化很容易变成空谈。

希望最后项目结构类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
probe_report/
├── .venv/
├── requirements.txt
├── README.md
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── config.py
│ ├── probes.py
│ ├── models.py
│ └── report.py
└── tests/
└── test_report.py

运行命令类似这样:

1
python -m app.main

输出类似这样:

1
2
3
4
巡检完成,总项数: 3
成功: 2
失败: 1
报告文件: output/report.json

这个项目不大,但已经足够覆盖“从脚本到工程”的关键问题。

三、先看最容易失控的版本

很多项目第一版都像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import json


def run():
config = {"env": "test", "token": "demo-token"}
results = []

auth_ok = True
order_ok = True
config_ok = False

results.append({"service": "auth", "ok": auth_ok})
results.append({"service": "order", "ok": order_ok})
results.append({"service": "config", "ok": config_ok})

print(results)
with open("report.json", "w", encoding="utf-8") as f:
f.write(json.dumps(results, ensure_ascii=False, indent=2))


if __name__ == "__main__":
run()

第一版这样写没问题,但只要功能继续加,就会出现:

  • 配置写死在代码里
  • 巡检逻辑和导出逻辑缠在一起
  • 主流程又做执行又做格式化
  • 后面加测试时很难拆

这就是脚本该开始工程化的信号。

四、第一步不是拆目录,而是先把运行环境绑住

1. 先给项目建独立虚拟环境

1
2
3
4
5
mkdir probe_report
cd probe_report
python3 -m venv .venv
source .venv/bin/activate
python -m pip install pytest

确认解释器位置:

1
which python

可能输出:

1
/path/to/probe_report/.venv/bin/python

这一步的意义很实际:

  • 当前项目用哪套解释器明确了
  • 依赖装在哪明确了
  • 后面别人接手时复现方式也明确了

2. 一个实际错误:把依赖装到全局环境

错误做法:

1
pip install pytest requests

这会让依赖和项目脱钩。

实际做法:

1
python -m pip install pytest requests

这样至少能保证安装动作和当前解释器对应。

五、依赖管理怎么做才不乱

1. 先记录最小依赖

对于这个小项目,requirements.txt 可以很简单:

1
2
pytest==8.2.0
requests==2.31.0

2. 新环境复现动作要固定

1
python -m pip install -r requirements.txt

3. 一个实际错误:导出一堆和项目无关的包

有些项目的 requirements.txt 会长成这样:

1
2
3
4
5
black==24.4.2
coverage==7.5.1
ipython==8.24.0
pytest==8.2.0
requests==2.31.0

如果当前项目只是个巡检工具,这种依赖文件通常已经开始混入“本机装过但项目未必依赖”的包。

这个阶段最稳的做法不是追求复杂工具链,而是先把真正需要的依赖记清楚。

六、包结构为什么不能只靠感觉拆

1. 先看这几个职责

对这个小项目来说,至少有四块职责:

  • 配置
  • 巡检逻辑
  • 结果模型
  • 报告导出

如果这些职责都堆在 main.py,后面一定会开始复制粘贴。

2. 先拆成最小包结构

更合适的目录是:

1
2
3
4
5
6
7
app/
├── __init__.py
├── main.py
├── config.py
├── probes.py
├── models.py
└── report.py

分工这样定:

  • config.py:环境、token、输出目录
  • probes.py:各类巡检函数
  • models.py:统一结果结构
  • report.py:展示和导出
  • main.py:串主流程

3. 一个实际错误:目录拆了,职责没拆

例如:

  • config.py 里也在跑请求
  • report.py 里也在判断业务是否成功
  • main.py 里还在拼 JSON

这就不是工程化,只是把乱代码切到了几个文件里。

七、先把统一结果结构抽出来

1. 不要每个地方都手写结果字典

app/models.py

1
2
3
4
5
6
def build_result(service, ok, message):
return {
"service": service,
"ok": ok,
"message": message,
}

2. 一个实际错误:结果字段不统一

错误写法:

1
2
{"service": "auth", "ok": True, "message": "ok"}
{"name": "order", "success": True, "msg": "ok"}

这会直接导致:

  • 汇总代码要兼容多套字段
  • 报告导出时容易出错

工程化的第一步之一,就是先把输出结构统一。

八、再把业务逻辑和导出逻辑拆开

1. 巡检逻辑放到 probes.py

app/probes.py

1
2
3
4
5
6
7
8
9
10
11
12
13
from app.models import build_result


def probe_auth():
return build_result("auth", True, "ok")


def probe_order():
return build_result("order", True, "ok")


def probe_config():
return build_result("config", False, "timeout")

2. 报告逻辑放到 report.py

app/report.py

1
2
3
4
5
6
7
8
9
10
11
12
13
import json


def print_summary(results):
failed = [item for item in results if not item["ok"]]
print(f"巡检完成,总项数: {len(results)}")
print(f"成功: {len(results) - len(failed)}")
print(f"失败: {len(failed)}")


def save_report(results, output_file):
with open(output_file, "w", encoding="utf-8") as f:
f.write(json.dumps(results, ensure_ascii=False, indent=2))

3. 主流程只负责串起来

app/main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from app.probes import probe_auth, probe_order, probe_config
from app.report import print_summary, save_report


def main():
results = [
probe_auth(),
probe_order(),
probe_config(),
]
print_summary(results)
save_report(results, "output/report.json")


if __name__ == "__main__":
main()

这时候主流程已经比单文件脚本清楚很多了。

九、配置应该放哪,才不会后面满地参数

1. 先把配置集中到一个地方

app/config.py

1
2
3
OUTPUT_FILE = "output/report.json"
ENV = "test"
TOKEN = "demo-token"

2. 一个实际错误:参数散在各处

错误写法通常像这样:

  • main.py 里写一个 timeout=3
  • probes.py 再写一个 env="test"
  • report.py 又手写一次输出路径

这样后面一改环境,三个文件都要翻。

3. 实际做法:从配置统一读取

1
2
3
4
from app.config import OUTPUT_FILE
from app.report import save_report

save_report(results, OUTPUT_FILE)

如果项目继续长大,再考虑配置文件或环境变量也不迟。

十、真正能运行的完整版本

下面给出一个最小但完整的工程化版本。

app/models.py

1
2
3
4
5
6
def build_result(service, ok, message):
return {
"service": service,
"ok": ok,
"message": message,
}

app/probes.py

1
2
3
4
5
6
7
8
9
10
11
12
13
from app.models import build_result


def probe_auth():
return build_result("auth", True, "ok")


def probe_order():
return build_result("order", True, "ok")


def probe_config():
return build_result("config", False, "timeout")

app/report.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import json
from pathlib import Path


def print_summary(results):
failed = [item for item in results if not item["ok"]]
print(f"巡检完成,总项数: {len(results)}")
print(f"成功: {len(results) - len(failed)}")
print(f"失败: {len(failed)}")


def save_report(results, output_file):
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps(results, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"报告文件: {output_path}")

app/config.py

1
2
3
OUTPUT_FILE = "output/report.json"
ENV = "test"
TOKEN = "demo-token"

app/main.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from app.config import OUTPUT_FILE
from app.probes import probe_auth, probe_order, probe_config
from app.report import print_summary, save_report


def main():
results = [
probe_auth(),
probe_order(),
probe_config(),
]
print_summary(results)
save_report(results, OUTPUT_FILE)


if __name__ == "__main__":
main()

执行:

1
python -m app.main

输出:

1
2
3
4
巡检完成,总项数: 3
成功: 2
失败: 1
报告文件: output/report.json

十一、怎么测试这一层是不是真的掌握了

这一层不能只看懂,要自己改一遍。

可以直接做这些动作:

  1. 增加一个新的巡检项
  2. 把输出路径改到 reports/ 目录
  3. print_summary()save_report() 补最小测试
  4. 把配置从常量改成环境变量读取
  5. probes.py 增加一个失败原因不同的巡检项

如果这些改动都能独立完成,说明项目从脚本到工程这一步已经开始真正会了。

十二、一个实际排错场景

这类项目里一个非常常见的实际问题是:python app/main.py 能跑,python -m app.main 却又是另一种行为,或者反过来。

这时排查顺序应该很直接:

  1. 先看项目是不是已经按包结构组织
  2. 再看导入是不是写成了 from app.xxx import ...
  3. 再看入口到底是按脚本运行还是按模块运行

如果项目已经开始走包结构,更稳定的执行方式通常是:

1
python -m app.main

因为这时 Python 会按模块方式理解整个包,而不是仅仅把某个文件当孤立脚本执行。

十三、一个实际练习

可以直接把这一篇变成一个完整练习。

练习目标:做一个“测试环境巡检报告项目”。

要求:

  1. 至少拆出 config.pyprobes.pyreport.pymain.py
  2. 使用独立虚拟环境
  3. 记录 requirements.txt
  4. 统一输出结构
  5. 生成一份报告文件
  6. 补一个实际错误场景,例如结果字段不统一或入口执行方式不一致

如果这个练习能独立做完,说明从脚本到工程这一步已经开始真正掌握。

十四、这篇文章学完以后,下一步应该补什么

如果这一篇已经能跟着做完,下一步最适合继续补的是:

  1. Python 做接口工具和数据处理脚本时,代码怎样写才不会越写越乱
  2. Python 写 HTTP 接口、命令行工具和定时任务时,目录和职责怎么拆
  3. pytest 的 fixture、mock 和测试数据怎么组织

因为到这一步,工程骨架已经有了。接下来更容易卡住的,是同一个项目开始出现多个入口、多种运行方式时,边界还能不能继续守住。

十五、结语

Python 项目从脚本到工程,最关键的不是先学多少工具,而是先把几件基础事情做稳:

  • 解释器和依赖跟项目绑定
  • 输出结构统一
  • 入口清楚
  • 职责分层

只要这几层先立住,哪怕项目继续长大,也还有继续整理的空间。