Python 命令行工具与项目结构

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Python 3.13

把脚本变成「能被人使用的工具」:清晰的命令、参数、帮助信息,以及可安装的目录结构。

环境准备

Python 3.13 自带 argparse,无需安装。建一个干净环境:

python -m venv .venv && source .venv/bin/activate

argparse 上手

# cli.py
import argparse

def main() -> None:
    parser = argparse.ArgumentParser(description="统计文件行数")
    parser.add_argument("path", help="文件路径")
    parser.add_argument("--encoding", default="utf-8", help="文件编码,默认 utf-8")
    parser.add_argument("-v", "--verbose", action="store_true", help="显示详细信息")

    args = parser.parse_args()

    with open(args.path, encoding=args.encoding) as f:
        lines = sum(1 for _ in f)

    if args.verbose:
        print(f"{args.path}: {lines} 行")
    else:
        print(lines)

if __name__ == "__main__":
    main()

运行:

python cli.py data.txt
python cli.py data.txt --encoding gbk -v
python cli.py --help      # 自动生成帮助

要点:

  • 位置参数不加前缀;可选参数加 - / --。
  • action="store_true":出现即为 True,不需要值。
  • default=:默认值。
  • help=:出现在 --help 里。

参数类型与校验

parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--ratio", type=float, default=1.0)
parser.add_argument("--level", choices=["debug", "info", "error"], default="info")
parser.add_argument("--tags", nargs="*", default=[], help="多个标签")
parser.add_argument("--retry", type=int, default=0)
  • type=int:不写的话 --port 8000 拿到的是字符串 "8000"。
  • choices:限定取值范围,非法值自动报错并退出码 2。
  • nargs="*" / "+":接收多个值,得到列表。

更复杂的校验用自定义函数:

def positive_int(value: str) -> int:
    n = int(value)
    if n <= 0:
        raise argparse.ArgumentTypeError("必须为正整数")
    return n

parser.add_argument("--workers", type=positive_int, default=1)

子命令

工具变大后需要 git 那样的子命令:

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="mytool", description="示例工具")
    sub = parser.add_subparsers(dest="command", required=True)

    count = sub.add_parser("count", help="统计行数")
    count.add_argument("path")

    find = sub.add_parser("find", help="查找关键字")
    find.add_argument("path")
    find.add_argument("keyword")
    find.add_argument("-i", "--ignore-case", action="store_true")

    return parser

def main() -> None:
    args = build_parser().parse_args()

    if args.command == "count":
        ...
    elif args.command == "find":
        ...

使用:

python cli.py count data.txt
python cli.py find data.txt error -i

退出码与输出

约定:成功 0,出错非 0(脚本里可用 sys.exit(1) 或 raise SystemExit(1)),参数错误由 argparse 自动返回 2。

import sys

def main() -> int:
    try:
        run()
    except FileNotFoundError as e:
        print(f"错误:{e}", file=sys.stderr)   # 错误信息走 stderr
        return 1
    return 0

if __name__ == "__main__":
    sys.exit(main())

让正常输出走 stdout、错误走 stderr,这样 mytool count a.txt > out.txt 不会把错误混进结果。

项目结构

可直接套用的布局(src layout,避免本地直接 import 到未安装的包):

mytool/
├── pyproject.toml
├── README.md
├── src/
│   └── mytool/
│       ├── __init__.py
│       ├── cli.py
│       └── core.py
└── tests/
    └── test_cli.py

pyproject.toml:

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

[project]
name = "mytool"
version = "0.1.0"
description = "示例命令行工具"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
mytool = "mytool.cli:main"

[tool.setuptools.packages.find]
where = ["src"]

可编辑安装后就能直接用命令名:

python -m pip install -e .
mytool count data.txt
mytool --help

用 python -m mytool.cli 也能运行,不需要安装。

拆分原则:core.py 放纯逻辑(可测试),cli.py 只负责解析参数、调用 core、打印结果。测试直接测 core 的函数,CLI 只做少量冒烟测试。

常见错误与注意事项

1. 手工解析 sys.argv

path = sys.argv[1]        # 没有 --help、没有校验、顺序一变就错

用 argparse,帮助信息和错误提示都是白送的。

2. 忘记 type=int

--port 拿到字符串,比较或参与运算时报错。

3. store_true 又给了默认值

parser.add_argument("--force", action="store_true", default=True)   # 永远为真

4. add_subparsers 忘了 required=True

老版本 Python 不带参数运行时 args.command 为 None,要在代码里判断。

5. 逻辑写在 cli.py 里导致无法测试

把逻辑放进 core.py,CLI 层只做参数解析与输出。

6. 忘记写 README 与 --help 说明

帮助文本是工具的门面,每个参数都写 help。

小结

  • argparse 处理位置参数、可选参数、类型、choices 与子命令。
  • 函数拆成 main() -> int,sys.exit(main()) 返回正确退出码。
  • 正常输出走 stdout,错误走 stderr。
  • 项目用 src layout + pyproject.toml,逻辑与 CLI 分层以便测试。

下一步