Python 现代开发学习笔记

(十五)配置、包入口与命令行检查

1. 把配置读取集中到入口

同一程序在练习目录和另一台电脑中使用不同数据库,业务函数不需要因此修改。配置按“命令行明确传入 → 环境变量 → 默认值”的次序取得,保存到一个对象中:

# 项目文件:quality_project/src/readingapp/config.py
# 只有调用 load_settings 时才读环境;显式参数优先于环境中的默认选择。
import os
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class Settings:
    database: Path
    log_file: Path
    debug: bool


def read_debug(text: str) -> bool:
    value = text.strip().lower()
    if value in {"1", "true", "yes", "on"}:
        return True
    if value in {"0", "false", "no", "off"}:
        return False
    raise ValueError("READING_DEBUG 应填写 true 或 false")


def load_settings(
    database: Path | None = None,
    log_file: Path | None = None,
    debug: bool | None = None,
) -> Settings:
    return Settings(
        database if database is not None else Path(os.getenv("READING_DB", "data/library.sqlite3")),
        log_file if log_file is not None else Path(os.getenv("READING_LOG", "logs/reading.log")),
        debug if debug is not None else read_debug(os.getenv("READING_DEBUG", "false")),
    )

os.getenv(名称, 默认值) 读取当前进程的环境变量,结果仍是文本。"false" 是非空字符串,直接 bool("false") 会得到 True,所以要明确解析。debug=None 表示命令行没有给出选择,debug=False 则是明确关闭;判断 is not None 保留这种区别。

相对数据路径从当前工作目录解释,默认会在启动目录中建立 data/ 与 logs/。调用者也可以传绝对路径。模块导入时不固定读取环境,测试和不同入口可以分别创建自己的 Settings。

同一配置项依次选择命令行值、环境变量与默认值,并保留 False 的明确含义
# 项目文件:quality_project/tests/unit/test_config.py
# 环境补丁在每次测试结束后恢复,不依赖运行电脑上原有的设置。
from pathlib import Path

import pytest

from readingapp.config import load_settings


def test_default_settings(monkeypatch):
    for name in ("READING_DB", "READING_LOG", "READING_DEBUG"):
        monkeypatch.delenv(name, raising=False)
    settings = load_settings()
    assert settings.database == Path("data/library.sqlite3")
    assert settings.log_file == Path("logs/reading.log")
    assert settings.debug is False


def test_arguments_override_environment(monkeypatch):
    monkeypatch.setenv("READING_DB", "environment.sqlite3")
    monkeypatch.setenv("READING_LOG", "environment.log")
    monkeypatch.setenv("READING_DEBUG", "true")
    settings = load_settings(Path("selected.sqlite3"), Path("selected.log"), False)
    assert settings.database == Path("selected.sqlite3")
    assert settings.log_file == Path("selected.log")
    assert settings.debug is False


@pytest.mark.parametrize("text, expected", [("true", True), ("false", False), (" 1 ", True)])
def test_environment_debug(monkeypatch, text, expected):
    monkeypatch.setenv("READING_DEBUG", text)
    assert load_settings().debug is expected


def test_bad_environment_value(monkeypatch):
    monkeypatch.setenv("READING_DEBUG", "可能")
    with pytest.raises(ValueError, match="READING_DEBUG"):
        load_settings()

PowerShell 可以用 $env:READING_DB = "data/practice.sqlite3" 给当前终端及其后续子进程设置变量;macOS/Linux 可以用 READING_DB=data/practice.sqlite3 python -m readingapp list。创建一个名为 .env 的文本文件并不会让 os.getenv 自动读取它;项目需要这类文件时,再显式添加读取动作。

2. 入口只负责选择动作与反馈

入口把配置和业务接起来,成功返回 0,已处理的操作失败返回 1,配置内容错误返回 2。下面两段依次放进同一个 cli.py:

# 项目文件:quality_project/src/readingapp/cli.py
# argparse 只解析启动参数;默认 None 留给 Settings 决定环境与默认值。
import argparse
import logging
import sqlite3
import sys
from collections.abc import Sequence
from datetime import date
from pathlib import Path

from bookdata.csv_io import write_csv
from bookdata.json_io import write_json
from bookdata.library import Library

from .config import load_settings
from .logging_setup import configure_logging
from .services import import_file

logger = logging.getLogger(__name__)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="带日志与测试的书籍库")
    parser.add_argument("--db", type=Path)
    parser.add_argument("--log-file", type=Path)
    parser.add_argument("--debug", action=argparse.BooleanOptionalAction, default=None)
    actions = parser.add_subparsers(dest="command", required=True)
    for name in ("import-json", "import-csv", "export-json", "export-csv"):
        action = actions.add_parser(name)
        action.add_argument("path", type=Path)
    listing = actions.add_parser("list")
    listing.add_argument("--tag")
    finish = actions.add_parser("finish")
    finish.add_argument("title")
    reading = actions.add_parser("log")
    reading.add_argument("title")
    reading.add_argument("day", type=date.fromisoformat)
    reading.add_argument("minutes", type=int)
    return parser

BooleanOptionalAction 同时生成 --debug 和 --no-debug,分别传 True、False;没有给出时保留 None。下面 main(argv=None) 允许终端默认读取 sys.argv,也允许测试直接传一组字符串:

# 续写同一文件:quality_project/src/readingapp/cli.py
# 日志保存调用信息,终端仅显示业务结果或简短错误;资源由上下文关闭。
def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        settings = load_settings(args.db, args.log_file, args.debug)
    except ValueError as error:
        print(f"配置错误:{error}", file=sys.stderr)
        return 2
    try:
        configure_logging(settings.log_file, settings.debug)
    except OSError as error:
        print(f"日志打开失败:{error}", file=sys.stderr)
        return 1
    try:
        if args.command in {"import-json", "import-csv"}:
            kind = "json" if args.command == "import-json" else "csv"
            count = import_file(args.path, settings.database, kind)
            print("已导入", count, "本")
            return 0
        with Library(settings.database) as library:
            if args.command in {"export-json", "export-csv"}:
                writer = write_json if args.command == "export-json" else write_csv
                books = library.export_books()
                writer(args.path, books)
                print("已导出", len(books), "本")
            elif args.command == "finish":
                library.finish(args.title)
                print("已标记", args.title)
            elif args.command == "log":
                library.record_reading(args.title, args.day, args.minutes)
                print("已记录", args.minutes, "分钟")
            else:
                rows = library.search(args.tag)
                if not rows:
                    print("书目为空")
                for row in rows:
                    status = "已读" if row["finished"] else "未读"
                    print(row["id"], row["title"], status, f"{row['total']}分钟")
        return 0
    except (OSError, ValueError, sqlite3.Error) as error:
        logger.exception("操作失败 command=%s", args.command)
        print(f"错误:{error}", file=sys.stderr)
        return 1

print(..., file=sys.stderr) 选择标准错误输出;没有写 file 的普通结果送往标准输出。参数格式错误仍由 argparse 给出说明和退出状态。没有捕获的程序错误会保留 traceback,继续按前面的定位过程处理。

# 项目文件:quality_project/src/readingapp/__init__.py
# 导入包时只建立命名空间,不打开数据库、不创建日志。
"""书籍库的配置、服务与命令入口。"""
# 项目文件:quality_project/src/readingapp/__main__.py
# python -m readingapp 与 reading-log 都调用同一个 main,并使用其返回状态。
from .cli import main

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

SystemExit 把返回值交给进程的退出状态;项目安装后生成的 reading-log 入口也会按同样方式处理 main 的返回值。包内用 .config 等相对导入,包外用 readingapp.config;运行入口使用 -m readingapp,让解释器建立正确的包上下文。

3. 分别检查入口输出与独立进程

# 项目文件:quality_project/tests/unit/test_cli_output.py
# 直接调用入口以检查两条输出流;文件日志初始化由进程测试另行检查。
from readingapp import cli


def test_empty_list_output(tmp_path, monkeypatch, capsys):
    monkeypatch.setattr(cli, "configure_logging", lambda path, debug: None)
    status = cli.main(["--db", str(tmp_path / "empty.sqlite3"), "list"])
    captured = capsys.readouterr()
    assert status == 0
    assert captured.out == "书目为空\n"
    assert captured.err == ""


def test_import_error_output(tmp_path, monkeypatch, capsys):
    monkeypatch.setattr(cli, "configure_logging", lambda path, debug: None)
    path = tmp_path / "broken.json"
    path.write_text("{", encoding="utf-8")
    database = tmp_path / "absent.sqlite3"
    status = cli.main(["--db", str(database), "import-json", str(path)])
    captured = capsys.readouterr()
    assert status == 1
    assert captured.out == ""
    assert "错误:JSON" in captured.err
    assert not database.exists()

这两项选择替换日志配置,避免改变 pytest 当前使用的根记录器;具体日志写入由真实子进程检查。启动独立进程用标准库 subprocess:

# 项目文件:quality_project/tests/e2e/test_cli_process.py
# 用当前环境的解释器在临时工作目录启动,两次进程只通过文件与数据库共享状态。
import os
import subprocess
import sys

from bookdata.json_io import read_json, write_json
from bookdata.models import Book


def run_cli(directory, *arguments):
    environment = dict(os.environ)
    for name in ("READING_DB", "READING_LOG", "READING_DEBUG"):
        environment.pop(name, None)
    environment["PYTHONUTF8"] = "1"
    return subprocess.run(
        [sys.executable, "-m", "readingapp", *arguments],
        cwd=directory,
        env=environment,
        capture_output=True,
        text=True,
        encoding="utf-8",
        timeout=20,
    )


def test_data_survives_processes(tmp_path):
    source = tmp_path / "books.json"
    write_json(source, [Book("Python", False, ["基础", "语法"])])
    imported = run_cli(tmp_path, "import-json", str(source))
    assert (imported.returncode, imported.stdout, imported.stderr) == (0, "已导入 1 本\n", "")
    for minutes in (20, 35):
        result = run_cli(tmp_path, "log", "Python", "2026-10-19", str(minutes))
        assert result.returncode == 0, result.stderr
    result = run_cli(tmp_path, "finish", "Python")
    assert result.returncode == 0, result.stderr
    listed = run_cli(tmp_path, "list", "--tag", "基础")
    assert (listed.returncode, listed.stdout, listed.stderr) == (0, "1 Python 已读 55分钟\n", "")
    destination = tmp_path / "export.json"
    exported = run_cli(tmp_path, "export-json", str(destination))
    assert exported.returncode == 0, exported.stderr
    assert read_json(destination) == [Book("Python", True, ["基础", "语法"])]
    assert "导入完成 count=1" in (tmp_path / "logs/reading.log").read_text(encoding="utf-8")


def test_bad_input_has_nonzero_status_and_traceback_log(tmp_path):
    source = tmp_path / "broken.json"
    source.write_text("{", encoding="utf-8")
    result = run_cli(tmp_path, "import-json", str(source))
    assert result.returncode == 1
    assert result.stdout == ""
    assert "错误:JSON" in result.stderr
    log = (tmp_path / "logs/reading.log").read_text(encoding="utf-8")
    assert "ERROR readingapp.cli 操作失败" in log
    assert "Traceback" in log
    assert not (tmp_path / "data/library.sqlite3").exists()

sys.executable 是当前环境的解释器路径,子进程因此使用同一份已安装项目。cwd 选择启动目录,env 指定环境,capture_output=True 收集两条输出流;text=True, encoding="utf-8" 把字节解码成文本;timeout 防止测试一直等待。结果的 returncode 是退出状态,stdout、stderr 是输出内容。subprocess

这里刻意从项目目录外启动,读到的仍是安装后的包;每次记录和查询都重新启动进程,55 因而来自持久化日志,不是旧对象中的列表。

笔记正文与全部题解 · Ctrl / ⌘ K