课程进度 课程大纲 已发布 24/24 课
Python 基础
数据与集合
构建可靠的程序
使用对象建模
专业 Python
高级 Python
三种名称,而不是一个“软件包”
一个可复用的命令有三种不同的名称。把它们混为一谈,会让打包显得很神秘;把它们区分开来,一切就很普通了。
-
导入包
import reading_report -
发行包
安装的是
reading-report -
命令
输入的是
study-report
这些名称可以相同,但并非必须相同。一个发行包可以包含多个导入包和命令。模块是导入包内部或外部的一个 .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"
- 前端 pip 或 build 发出工作请求
- [build-system] 列出所需的构建工具
- 后端 setuptools 构建产物
- [project] 描述发行包
构建要求不是运行时依赖。requires-python 声明兼容性;它不会安装 Python。[project.scripts] 的意思是:“导入 reading_report.cli,找到可调用对象 main,然后调用它。”安装过程会生成对应平台的启动程序。
让命令入口保持精简
终端文本应该依次经过三个小房间:
- 解析 字符串变成经过检查的值
- 领域逻辑 不依赖终端进行计算
- 呈现 把结果转换成文本
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 版本和仓库标签。
三个小任务与收尾检查
- 重命名三扇门。 设计发行包
reading-tools、导入包reading_tools和命令reading-summary;写出对应的脚本入口。 - 添加
--heading。 将它从解析层传递到展示层,不要把格式化逻辑移入cli.py;测试默认文本和自定义文本。 - 测试错误输入。 检查缺少冒号、主题为空、分钟数不是数字以及目标为负数的情况。确认诊断信息简洁,并且解析器返回非零状态码。
容易踩到的坑:只从项目根目录导入会掩盖布局错误,元数据变更可能需要重新安装,解析器错误会以状态码 2 退出,而上传是一项具有持久影响的外部操作。
- 我可以区分模块、导入包、发行包和命令。
- 我可以解释构建前端、后端和项目元数据。
- 我可以把脚本名称映射到一个可调用对象。
- 我会把解析、领域逻辑和展示分开。
- 我可以使用
python -m、venv、可编辑安装和unittest。 - 我知道生成的产物既不是源代码,也不代表获得了发布许可。
- 我完成了三个小任务。
接下来,你将学习 Python 语法如何让自己的对象进行表示、比较、哈希、包含判断和迭代。