(十五)配置、包入口与命令行检查
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。
# 项目文件: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 parserBooleanOptionalAction 同时生成 --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 1print(..., 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 因而来自持久化日志,不是旧对象中的列表。