春江暮客

春江暮客的个人学习分享网站

Python argparse 子命令实战:必需操作、参数范围与特殊文件名

2026-11-01 技术
Python argparse 子命令实战:必需操作、参数范围与特殊文件名

脚本最初只有一个功能,后来又加了预览、统计和一串可选参数:漏传操作时不知道该做什么,输错参数缩写却意外执行,文件名以 - 开头又被当成选项。把操作拆成明确的子命令,可以让这些错误在读取文件之前就被发现。

本文面向初级到中级读者,用标准库 argparse 写一个只读文本工具:count 统计行数,preview 查看前几行。代码不修改输入文件,也不安装依赖或改动系统配置。

1. 方法:把操作和参数分开

  1. 必须选择 count 或 preview,不能省略操作。每个子命令绑定一个函数。
  2. --lines 只属于 preview,范围为 1–1000;默认 5。count 不接受这个选项。
  3. 主解析器和两个子解析器都关闭长选项缩写,避免 --lin 被当成 --lines。
  4. 只有解析成功后才打开文件。参数错误返回 2;读取或解码失败返回 1;成功返回 0,stdout 只输出一份 JSON。

Python 官方子命令文档给出了 add_subparsers() 和 set_defaults() 的用法。本文把解析规则集中在 build_parser(),业务函数不再直接索引 sys.argv。

若脚本已经从配置文件读取默认值,可以结合 tomllib 任务配置教程,但不要让子命令的默认值无意覆盖配置中的显式设置。

2. 准备:创建独立演示目录

使用现有 Python 3.9 或更新版本,无需 pip 安装。下面的命令在 Linux、Python 3.11.4 下验证,可在 bash 或 zsh 中执行。

  1. 在同一个终端执行,重复实验时使用新的目录名,避免覆盖旧文件:
python3 --version
mkdir text-cli-demo
cd text-cli-demo
python3 - <<'PY_DATA'
from pathlib import Path
Path("sample.txt").write_text("alpha\n你好\nlast", encoding="utf-8")
Path("-sample.txt").write_bytes(Path("sample.txt").read_bytes())
PY_DATA
  1. 将下一节代码保存为当前目录的 text_tool.py。演示文件最后一行没有换行符,仍应计为一行。

3. 实现:必需子命令、范围检查与函数分发

"""Read a local UTF-8 text file using count or preview. Python 3.9+."""
import argparse
from itertools import islice
import json
from pathlib import Path
import sys


def line_limit(raw):
    try:
        value = int(raw)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("lines must be an integer in 1..1000") from exc
    if not 1 <= value <= 1000:
        raise argparse.ArgumentTypeError("lines must be an integer in 1..1000")
    return value


def count_file(args):
    with args.path.open(encoding="utf-8") as stream:
        total = sum(1 for _ in stream)
    return {"command": "count", "path": str(args.path), "lines": total}


def preview_file(args):
    with args.path.open(encoding="utf-8") as stream:
        lines = [line.removesuffix("\n") for line in islice(stream, args.lines)]
    return {
        "command": "preview", "path": str(args.path),
        "lines": lines, "limit": args.lines,
    }


def build_parser():
    parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
    commands = parser.add_subparsers(dest="command", required=True)
    count = commands.add_parser("count", help="count text lines", allow_abbrev=False)
    count.add_argument("path", type=Path, help="local UTF-8 text file")
    count.set_defaults(func=count_file)
    preview = commands.add_parser("preview", help="preview first lines", allow_abbrev=False)
    preview.add_argument("path", type=Path, help="local UTF-8 text file")
    preview.add_argument("--lines", type=line_limit, default=5, help="1..1000 (default: 5)")
    preview.set_defaults(func=preview_file)
    return parser


def main():
    args = build_parser().parse_args()
    try:
        result = args.func(args)
    except (OSError, UnicodeError) as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    print(json.dumps(result, ensure_ascii=True))
    return 0


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

type=Path 只把字符串转换为路径对象,不检查文件是否存在。实际打开文件放在业务函数中,且在 with 结束后才生成成功报告,失败时不会留下半份 JSON。

argparse 的 type 参数可接收转换函数;这里把无效整数和越界值统一转成 ArgumentTypeError。它们不是文件错误,因此由参数解析阶段返回 2。主解析器的设置不是子解析器的全局开关,所以每个 add_parser() 都显式传入 allow_abbrev=False。

文件按 UTF-8 严格解码,文本模式将 CRLF、CR 和 LF 换行统一为 \n,详见 open() 官方说明。count 统计迭代得到的文本行,不等同于统计字节中的换行符;空文件为 0,最后一行不必有换行符。

preview 使用 islice()限制行数,只移除行末的一个换行符,保留空行和其他空白。行数限制不是字节或内存上限:单行很长时仍会占用较多内存;解码器也可能预读后面的数据。它不是完整文件的编码校验器。本文仅用于可信的普通本地文本文件,不用于设备、管道或无限数据源。

4. 运行:帮助、成功输出与特殊文件名

  1. 先查看帮助,再运行两个操作:
python3 text_tool.py --help
python3 text_tool.py preview --help
python3 text_tool.py count sample.txt
python3 text_tool.py preview --lines 2 sample.txt

后两条命令应分别输出:

{"command": "count", "path": "sample.txt", "lines": 3}
{"command": "preview", "path": "sample.txt", "lines": ["alpha", "\u4f60\u597d"], "limit": 2}

ensure_ascii=True 让 JSON 用转义形式表示中文,避免输出终端编码影响结果;json.loads() 会还原为中文,不是内容丢失。

  1. 文件名以 - 开头时,在子命令的选项之后放 --,再传文件名:
python3 text_tool.py preview --lines 2 -- -sample.txt

输出中的 path 应为 -sample.txt,lines 仍为 alpha 和 你好。-- 后面的所有内容都按位置参数处理,因此不要把 --lines 放到它后面;./-sample.txt 也是一种避免歧义的写法。

  1. 手动检查两类失败。请在交互终端执行;启用 set -e 的脚本可能在第一条失败命令后停止:
python3 text_tool.py preview --lines 0 sample.txt
echo "exit=$?"
python3 text_tool.py count missing.txt
echo "exit=$?"

第一组显示参数用法和 lines must be an integer in 1..1000,随后 exit=2;第二组显示 ERROR:,随后 exit=1。自动化脚本应检查退出码,不要只检查报告文件是否存在。调度环境路径不一致时,可按 cron 中的 Python 路径与工作目录排查固定解释器和工作目录。

5. 验证:用断言覆盖成功和错误分支

将以下代码保存为 test_text_tool.py,放在 text_tool.py 旁边。它在当前目录创建独立临时目录,结束时自动清理,不覆盖演示输入:

"""Run beside text_tool.py; only temporary fixture files are created."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile

script = Path("text_tool.py").resolve()


def run(folder, *args):
    return subprocess.run(
        [sys.executable, str(script), *args], cwd=folder,
        capture_output=True, text=True, check=False,
    )


with tempfile.TemporaryDirectory(prefix="cli-test-", dir=".") as name:
    folder = Path(name).resolve()
    sample = folder / "sample.txt"
    sample.write_bytes("alpha\n你好\nlast".encode("utf-8"))
    (folder / "-sample.txt").write_bytes(sample.read_bytes())
    (folder / "empty.txt").write_bytes(b"")
    (folder / "crlf.txt").write_bytes(b"one\r\ntwo\r\n")
    (folder / "bad.txt").write_bytes(b"\xff")
    before = {p.name: p.read_bytes() for p in folder.iterdir()}
    for filename, expected in [("sample.txt", 3), ("empty.txt", 0), ("crlf.txt", 2)]:
        result = run(folder, "count", filename)
        assert result.returncode == 0 and result.stderr == "", result
        assert json.loads(result.stdout)["lines"] == expected
    result = run(folder, "preview", "--lines", "2", "--", "-sample.txt")
    assert result.returncode == 0 and result.stderr == "", result
    assert json.loads(result.stdout) == {
        "command": "preview", "path": "-sample.txt",
        "lines": ["alpha", "你好"], "limit": 2,
    }
    result = run(folder, "preview", "sample.txt")
    assert result.returncode == 0 and json.loads(result.stdout)["limit"] == 5
    for args in [[], ["unknown"], ["count"], ["count", "--lines", "2", "sample.txt"],
                 ["preview", "--lin", "2", "sample.txt"],
                 *[["preview", "--lines", value, "sample.txt"]
                   for value in ["0", "-1", "1001", "abc"]]]:
        result = run(folder, *args)
        assert result.returncode == 2 and result.stdout == "" and result.stderr, result
    for filename in ["missing.txt", "bad.txt"]:
        result = run(folder, "count", filename)
        assert result.returncode == 1 and result.stdout == "", result
        assert result.stderr.startswith("ERROR:"), result
    for args in [["--help"], ["preview", "--help"]]:
        result = run(folder, *args)
        assert result.returncode == 0 and "usage:" in result.stdout and not result.stderr
    assert before == {p.name: p.read_bytes() for p in folder.iterdir()}
print("PASS: subcommands, limits, --, UTF-8, newlines, errors, help, unchanged inputs")

执行:

python3 test_text_tool.py

期望输出:

PASS: subcommands, limits, --, UTF-8, newlines, errors, help, unchanged inputs

测试检查中文、末行无换行、空文件、CRLF、帮助、必需子命令、越界参数、拒绝缩写、错误退出码,以及输入文件字节不变。它不是性能基准,也不模拟权限变化和并发文件修改。

6. 故障排查

现象 具体修复
提示缺少 command 在脚本名后加 count 或 preview,不要去掉 required=True 来掩盖漏参。
count 不接受 --lines 改用 preview --lines 2 sample.txt;不同操作只接收自己的选项。
--lin 报错 写完整的 --lines,保留关闭缩写的设置。
-sample.txt 被当成选项 使用 preview --lines 2 -- -sample.txt,或传 ./-sample.txt。
ERROR: 中含解码错误 先确认源文件真实编码,再转换成 UTF-8;不要用 errors="ignore" 静默丢字。
只想看日志末尾 本文预览的是开头;使用 deque 读取日志末尾的方法。

7. 总结

让子命令表达操作,让参数转换函数负责类型和范围,再把业务逻辑绑定到独立函数。先验证命令行,再读取文件,并用退出码区分参数错误与运行错误,就能把小脚本稳定地接入自动化。新增操作时,为它增加独立子解析器和对应测试,而不是继续堆叠含义模糊的开关。

标签

1024 12306 2fa ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena concurrency configparser configuration copy cprofile cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging decimal deployment desktop-app devtools difflib disk-space disown dns docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 etag evaluation faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io file-management files flashppi flask folium frontend game generator git github-actions google google-research gpt-6.1-sol grep gzip harness heapq hls html http hugo imports indexnow ipaddress jailbreak javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp memory mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networking networkx nginx normalize numpy ollama omegatherm pandas password pathlib pdb pep-723 performance permissions phaser pillow pip postfix preprocessing print prompt-engineering protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sitemap sklearn socket solana somaticsignatures sorting spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tarfile tcp tempfile tensorflow tkinter tls toml totp tracemalloc tron tronpy troubleshooting turtle typesafe-ai ubuntu unicode urllib usdt utf-8 uv venv vhhbert vite web-tools webp wordcloud wordpress workflow xml yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它