JEPA4Japan · 教程

构建命令行包

1,849字 6分钟阅读 #Python

将一组模块转变为带有稳定接口的可安装命令。

课程进度 课程大纲 已发布 24/24 课

三种名称,而不是一个“软件包”

一个可复用的命令有三种不同的名称。把它们混为一谈,会让打包显得很神秘;把它们区分开来,一切就很普通了。

  1. 导入包 import reading_report
  2. 发行包 安装的是 reading-report
  3. 命令 输入的是 study-report
Python 导入代码,安装程序记录发行包,shell 启动命令。

这些名称可以相同,但并非必须相同。一个发行包可以包含多个导入包和命令。模块是导入包内部或外部的一个 .py 文件。

绘制项目结构图并明确构建约定

src 布局可以让可导入的代码与仓库根目录分开:

reading-report/
├── pyproject.toml
├── README.md
├── src/reading_report/
│   ├── __init__.py
│   ├── __main__.py
│   ├── cli.py
│   ├── domain.py
│   └── presentation.py
└── tests/test_cli.py

这样可以发现意外的导入:它们之所以能正常工作,只是因为当前目录恰好包含这个包。测试应该使用已安装的项目,或者在安装前检查时有意设置 PYTHONPATH=src。

pyproject.toml 包含两种不同的约定:

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

[project]
name = "reading-report"
version = "0.1.0"
description = "Summarize study sessions"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []

[project.scripts]
study-report = "reading_report.cli:main"
  1. 前端 pip 或 build 发出工作请求
  2. [build-system] 列出所需的构建工具
  3. 后端 setuptools 构建产物
  4. [project] 描述发行包
构建前端向声明的后端发出请求;项目元数据描述正在构建的内容。

构建要求不是运行时依赖。requires-python 声明兼容性;它不会安装 Python。[project.scripts] 的意思是:“导入 reading_report.cli,找到可调用对象 main,然后调用它。”安装过程会生成对应平台的启动程序。

让命令入口保持精简

终端文本应该依次经过三个小房间:

  1. 解析 字符串变成经过检查的值
  2. 领域逻辑 不依赖终端进行计算
  3. 呈现 把结果转换成文本
解析、领域逻辑和呈现可以各自独立测试。

main(argv=None) 在函数内部解析参数、输出呈现结果,并返回一个整数。零表示成功。在最外层边界,__main__.py 或已安装的启动程序会把这个返回值转换为进程状态码。领域函数不应该调用 sys.exit()。

安装之前,python -m 可以运行同一个包入口点,同时让相对导入保持正常工作:

PYTHONPATH=src python -m reading_report Python:45 Reading:20 --goal 60

构建完整的 study-report 项目

将以下内容放入 src/reading_report/domain.py:

from dataclasses import dataclass


@dataclass(frozen=True)
class Session:
    topic: str
    minutes: int

    def __post_init__(self):
        topic = self.topic.strip()
        if not topic:
            raise ValueError("topic must not be empty")
        if isinstance(self.minutes, bool) or not isinstance(self.minutes, int):
            raise ValueError("minutes must be an integer")
        if self.minutes < 0:
            raise ValueError("minutes must not be negative")
        object.__setattr__(self, "topic", topic)


def total_minutes(sessions):
    return sum(session.minutes for session in sessions)

将以下内容放入 src/reading_report/presentation.py:

from .domain import total_minutes


def render_report(sessions, goal):
    total = total_minutes(sessions)
    status = "met" if total >= goal else "not met"
    lines = ["Study report"]
    lines.extend(f"- {item.topic}: {item.minutes} minutes" for item in sessions)
    lines.append(f"Total: {total} minutes")
    lines.append(f"Goal: {status} ({goal} minutes)")
    return "\n".join(lines)

将以下内容放入 src/reading_report/cli.py:

import argparse

from .domain import Session
from .presentation import render_report


def nonnegative_int(text):
    try:
        number = int(text)
    except ValueError as error:
        raise argparse.ArgumentTypeError("must be an integer") from error
    if number < 0:
        raise argparse.ArgumentTypeError("must not be negative")
    return number


def parse_session(text):
    topic, separator, minutes = text.partition(":")
    if not separator:
        raise argparse.ArgumentTypeError("use TOPIC:MINUTES")
    try:
        return Session(topic, nonnegative_int(minutes))
    except (ValueError, argparse.ArgumentTypeError) as error:
        raise argparse.ArgumentTypeError(str(error)) from error


def main(argv=None):
    parser = argparse.ArgumentParser(prog="study-report")
    parser.add_argument("sessions", nargs="+", type=parse_session)
    parser.add_argument("--goal", type=nonnegative_int, default=60)
    args = parser.parse_args(argv)
    print(render_report(args.sessions, args.goal))
    return 0

使用以下这些简短的边界文件:

# src/reading_report/__init__.py
from .domain import Session, total_minutes

__all__ = ["Session", "total_minutes"]
# src/reading_report/__main__.py
from .cli import main

if __name__ == "__main__":
    raise SystemExit(main())

创建 tests/test_cli.py:

import contextlib
import io
import unittest

from reading_report.cli import main, parse_session
from reading_report.domain import Session, total_minutes


class CommandTests(unittest.TestCase):
    def test_domain(self):
        self.assertEqual(total_minutes([Session("Python", 30)]), 30)

    def test_parse(self):
        self.assertEqual(parse_session(" Python :25"), Session("Python", 25))

    def test_main(self):
        output = io.StringIO()
        with contextlib.redirect_stdout(output):
            status = main(["Python:45", "Reading:20", "--goal", "60"])
        self.assertEqual(status, 0)
        self.assertIn("Goal: met (60 minutes)", output.getvalue())


if __name__ == "__main__":
    unittest.main()

使用 PYTHONPATH=src 运行命令和测试。确认输出中包含 Total: 65 minutes、Goal: met (60 minutes)、三个 ok 结果,以及最后的 OK。

有意识地开发、构建和发布

开发期间,请使用项目专属环境和可编辑安装:

python -m venv .venv
source .venv/bin/activate
python -m pip install --editable .
python -m unittest discover -s tests -v

Windows PowerShell 的激活命令是 .venv\Scripts\Activate.ps1。可编辑安装会让导入指向 src;新增脚本等元数据变更可能需要重新安装。请忽略 .venv/、build/、dist/、*.egg-info/、缓存和覆盖率输出。

构建和发布是明确的外部操作。以下命令需要另行安装前端工具、审查元数据、选择凭据并获得明确授权;它们在这里仅作为文档展示,不是本教程实际执行的步骤:

python -m build
python -m twine upload dist/*

检查 wheel 和源码归档,在全新环境中安装 wheel,重新运行测试和命令,并在上传前确认名称、版本、README、许可证、支持的 Python 版本和仓库标签。

三个小任务与收尾检查

  1. 重命名三扇门。 设计发行包 reading-tools、导入包 reading_tools 和命令 reading-summary;写出对应的脚本入口。
  2. 添加 --heading。 将它从解析层传递到展示层,不要把格式化逻辑移入 cli.py;测试默认文本和自定义文本。
  3. 测试错误输入。 检查缺少冒号、主题为空、分钟数不是数字以及目标为负数的情况。确认诊断信息简洁,并且解析器返回非零状态码。

容易踩到的坑:只从项目根目录导入会掩盖布局错误,元数据变更可能需要重新安装,解析器错误会以状态码 2 退出,而上传是一项具有持久影响的外部操作。

  • 我可以区分模块、导入包、发行包和命令。
  • 我可以解释构建前端、后端和项目元数据。
  • 我可以把脚本名称映射到一个可调用对象。
  • 我会把解析、领域逻辑和展示分开。
  • 我可以使用 python -m、venv、可编辑安装和 unittest。
  • 我知道生成的产物既不是源代码,也不代表获得了发布许可。
  • 我完成了三个小任务。

接下来,你将学习 Python 语法如何让自己的对象进行表示、比较、哈希、包含判断和迭代。