2. 从脚本到项目

很多Python学习者的第一个“作品”是一个单文件脚本:几十到几百行,顶部一堆 import,中间若干函数,末尾一段 if __name__ == "__main__":。它能跑通,也能解决眼前的问题,但它还不是一个“项目”。本节的任务,就是拿一个真实脚本当样本,逐层识别它的工程化需求,并据此划出源码、测试、文档、配置与构建产物五类边界。边界划清楚了,后续的依赖声明、打包、发布才有落脚点。

一、先看一个真实脚本长什么样

假设我们有一个名为 logstat.py 的脚本,用途是统计Nginx访问日志中各状态码的出现次数,并输出Top N。它的典型形态如下:

import sys, re, json
from collections import Counter

LOG_RE = re.compile(r'"\s+(\d{3})\s+')

def parse(path):
    c = Counter()
    with open(path, encoding="utf-8") as f:
        for line in f:
            m = LOG_RE.search(line)
            if m:
                c[m.group(1)] += 1
    return c

def topn(counter, n=5):
    return counter.most_common(n)

if __name__ == "__main__":
    path = sys.argv[1]
    n = int(sys.argv[2]) if len(sys.argv) > 2 else 5
    result = topn(parse(path), n)
    print(json.dumps(dict(result), ensure_ascii=False, indent=2))

这个脚本已经具备可运行性,但它的“工程化缺口”非常典型:参数靠 sys.argv 位置解析,没有帮助信息;正则写死,无法适配不同日志格式;没有异常处理,文件不存在会直接抛栈;没有测试;没有说明文档;没有声明依赖(虽然本例只用标准库,但真实脚本往往不是);输出格式固定,无法被其他程序复用。这些缺口,正是工程化需求的来源。

二、五类边界的划分依据

把脚本升级为项目,本质是把“混在一个文件里的关注点”拆开,并给每一类内容一个明确的存放位置和职责。下面这张图给出了五类边界的划分逻辑与它们之间的依赖方向:源码是核心,测试依赖源码,文档描述源码,配置驱动源码的运行与构建,构建产物由源码和配置共同生成,且不应被手工修改。

脚本工程化的五类边界与依赖方向 源码 src/ logstat/__init__.py logstat/cli.py · parser.py 测试 tests/ test_parser.py fixtures/sample.log 文档 docs/ README.md usage.md · CHANGELOG 配置 pyproject.toml [project] · [build-system] [tool.pytest] · .gitignore 构建产物 dist/ logstat-0.1.0-py3-none-any.whl logstat-0.1.0.tar.gz 依赖 描述 驱动 生成 引用 边界原则:源码不读配置、测试不改源码、产物不手工编辑

三、源码边界:从“一个文件”到“一个包”

源码边界的核心判断标准是:哪些代码是“被复用的逻辑”,哪些是“一次性的入口”。在 logstat.py 中,parse() 和 topn() 是可复用逻辑,if __name__ == "__main__" 下的参数解析和打印是入口逻辑。工程化后,应拆成包结构:

src/
└── logstat/
    ├── __init__.py      # 暴露 parse、topn
    ├── parser.py        # 日志解析,纯函数
    └── cli.py           # 命令行入口,负责参数与输出

这样拆分的收益是:parser.py 不依赖命令行,可以被测试直接导入;cli.py 只负责“把用户输入翻译成函数调用”,逻辑薄、易替换。源码边界内还有一个隐含要求:不出现硬编码路径。原脚本中 open(path) 的 path 来自参数,这是对的;但如果写成 open("/var/log/nginx/access.log"),就必须改为由配置或参数注入。

四、测试边界:用固定输入锁定行为

测试边界要回答的问题是:我怎么知道改动没有破坏原有行为? 对 logstat 来说,最值得测的是 parse() 的解析正确性和 topn() 的排序正确性。测试不应依赖真实日志文件,而应使用固定的小样本:

# tests/fixtures/sample.log
127.0.0.1 - - [10/Oct/2024] "GET /a HTTP/1.1" 200 123
127.0.0.1 - - [10/Oct/2024] "GET /b HTTP/1.1" 404 0
127.0.0.1 - - [10/Oct/2024] "GET /c HTTP/1.1" 200 456

对应测试断言 parse("tests/fixtures/sample.log") 返回 {"200": 2, "404": 1},topn(counter, 1) 返回 [("200", 2)]。测试边界的关键约定是:测试只读源码和固定样本,不写源码、不依赖网络、不依赖当前时间。凡是需要“当前时间”或“随机数”的逻辑,都应通过参数注入,否则测试无法稳定复现。

五、文档、配置与构建产物边界

文档边界的最小集合是 README.md:说明这个工具做什么、怎么安装、怎么用、输入输出是什么。对 logstat,README 中应给出真实可复制的命令:

pip install .
logstat /var/log/nginx/access.log 10
# 输出:{"200": 1523, "404": 87, ...}

配置边界的核心是 pyproject.toml。它同时承担三件事:声明项目元信息(名称、版本、Python版本要求)、声明依赖、声明构建后端。一个最小可用示例如下:

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "logstat"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = []

[project.scripts]
logstat = "logstat.cli:main"

其中 [project.scripts] 把 logstat 命令映射到 cli.main,这是“脚本”变成“可安装命令”的关键一步。构建产物边界则最容易被忽视:dist/、build/、*.egg-info/ 都是生成物,不应提交到版本库,也不应手工修改。它们由 python -m build 从源码和配置生成,删除后可以随时重建。判断一个目录是否属于构建产物,只需问一句:删掉它,源码和配置还在,能否重新生成?能,就是产物。

边界划分的实用检查法:打开项目根目录,逐个问“这个文件/目录,删掉后是否影响源码可读性、测试可运行性、文档可理解性、配置可重建性”。四者皆否,它大概率是构建产物或临时文件,应写入 .gitignore。

六、把边界落到目录树上

综合以上五类边界,logstat 的工程化目录结构可以整理为:

logstat/
├── src/logstat/          # 源码边界
│   ├── __init__.py
│   ├── parser.py
│   └── cli.py
├── tests/                # 测试边界
│   ├── fixtures/sample.log
│   └── test_parser.py
├── docs/                 # 文档边界
│   └── usage.md
├── README.md
├── pyproject.toml        # 配置边界
├── .gitignore
└── dist/                 # 构建产物边界(不提交)

需要强调的是,边界不是“文件夹越多越工程化”。一个只有200行逻辑的工具,把测试和源码放在同一层、把文档合并进 README,同样成立。边界的意义在于职责清晰、依赖方向单一:源码不反向依赖测试,测试不修改源码,配置不混入业务逻辑,产物不被当作源码编辑。做到这四点,脚本就已经完成了从“能跑”到“可维护、可打包、可发布”的关键一步。