Skip to content

编写完整脚本 ​

前面几节学习的语法片段,最终都要组装成可以独立运行的程序。一个「完整的脚本」不只是能跑通,还应该:接收命令行参数、处理异常、输出日志、返回正确的退出码、便于他人复用。本节将从零写一个命令行工具,讲清 __name__ == "__main__"、argparse、异常处理、文件读写与退出码等工程要点。

前置知识

阅读本节前,建议先了解:定义函数,掌握函数封装与参数传递。

基础概念 ​

脚本(Script) 是可直接由解释器执行的 .py 文件;模块(Module) 是可被 import 的文件——同一个文件既是脚本也是模块,区别只在使用方式。

  • 程序入口(Entry Point):脚本开始执行的位置,Python 惯例是 if __name__ == "__main__":。
  • __name__:模块的内置变量。直接被运行时其值为 "__main__";被导入时其值为模块名。
  • 退出码(Exit Code):进程结束时返回给系统的整数,0 表示成功,非 0 表示失败——这是被 CI / 调度系统判断成功与否的依据。
__name__ 的值场景
"__main__"文件被直接执行
"模块名"文件被 import 导入

语法与用法 ​

一个结构完整的脚本骨架:

python
# 最简可运行示例
import sys

def main() -> int:
    """程序主逻辑,返回退出码"""
    name = sys.argv[1] if len(sys.argv) > 1 else "世界"
    print(f"你好,{name}!")
    return 0

if __name__ == "__main__":
    sys.exit(main())
bash
$ python greet.py
你好,世界!
$ python greet.py Python
你好,Python!

详细说明 ​

name 与程序入口 ​

python
# 文件名:calculator.py
def add(a: float, b: float) -> float:
    """两数相加"""
    return a + b

def main() -> int:
    print(f"2 + 3 = {add(2, 3)}")
    return 0

# 只有直接运行本文件时才执行;被 import 时不执行
if __name__ == "__main__":
    raise SystemExit(main())
python
# 另一个文件中导入:main() 不会被执行
from calculator import add

print(add(10, 20))        # 直接复用函数
text
30

为什么推荐 raise SystemExit(main())

SystemExit(main()) 既执行了 main(),又把它返回的整数作为进程退出码。相比只写 main(),调度脚本与 CI 能正确感知成败。

命令行参数处理 ​

早期用 sys.argv 手工解析;参数一多就应改用 argparse,它自动生成帮助信息、校验类型、支持子命令:

python
# 文件名:stats.py
import argparse

def build_parser() -> argparse.ArgumentParser:
    """构建命令行参数解析器"""
    parser = argparse.ArgumentParser(
        prog="stats",
        description="统计文本文件中的单词数量",
    )
    parser.add_argument("path", help="要统计的文件路径")
    parser.add_argument(
        "-n", "--top",
        type=int,
        default=5,
        help="显示出现次数最多的前 N 个单词(默认 5)",
    )
    parser.add_argument(
        "-i", "--ignore-case",
        action="store_true",             # 出现即为 True
        help="忽略大小写",
    )
    return parser

def main() -> int:
    args = build_parser().parse_args()
    print(f"文件:{args.path}")
    print(f"显示前 {args.top} 个,忽略大小写:{args.ignore_case}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
bash
$ python stats.py data.txt -n 3 --ignore-case
文件:data.txt
显示前 3 个,忽略大小写:True

$ python stats.py --help
usage: stats [-h] [-n TOP] [-i] path

异常处理 ​

生产脚本要能优雅地处理可预期的错误(文件不存在、格式错误、网络超时),而不是抛出一堆堆栈:

python
def read_numbers(path: str) -> list[float]:
    """读取文件中的数字,遇到问题返回空列表并给出提示"""
    numbers: list[float] = []
    try:
        with open(path, encoding="utf-8") as file:
            for lineno, line in enumerate(file, start=1):
                line = line.strip()
                if not line:
                    continue
                try:
                    numbers.append(float(line))
                except ValueError:
                    print(f"警告:第 {lineno} 行不是数字,已跳过:{line!r}")
    except FileNotFoundError:
        print(f"错误:文件不存在 -> {path}")
    except PermissionError:
        print(f"错误:没有读取权限 -> {path}")
    except OSError as exc:
        print(f"错误:读取失败 -> {exc}")
    return numbers

print(read_numbers("不存在.txt"))
text
错误:文件不存在 -> 不存在.txt
[]
python
# 多个异常类型合并捕获,用 as 获取异常对象
def safe_int(text: str) -> int | None:
    """把字符串转成整数,失败返回 None"""
    try:
        return int(text)
    except (ValueError, TypeError) as exc:
        print(f"转换失败:{text!r}({type(exc).__name__})")
        return None

print(safe_int("42"))
print(safe_int("abc"))
print(safe_int(None))            # TypeError 也会被捕获
text
42
转换失败:'abc'(ValueError)
None
转换失败:None(TypeError)
None

不要裸捕获 except:

except: 会连 KeyboardInterrupt(Ctrl+C)和 SystemExit 一起吞掉,导致程序无法中断。至少写 except Exception:,并避免 try 块里包含过多代码。

自定义异常与抛出 ​

python
class ConfigError(Exception):
    """配置错误"""

class Config:
    """应用配置,负责校验必要字段"""

    def __init__(self, data: dict[str, object]) -> None:
        try:
            self.host = str(data["host"])
            self.port = int(data["port"])           # type: ignore[arg-type]
        except KeyError as exc:
            raise ConfigError(f"缺少必要配置项:{exc}") from exc
        except (ValueError, TypeError) as exc:
            raise ConfigError(f"配置项类型错误:{exc}") from exc

        if not 0 < self.port < 65536:
            raise ConfigError(f"端口超出范围:{self.port}")

for raw in [
    {"host": "127.0.0.1", "port": 8080},
    {"host": "127.0.0.1"},
    {"host": "127.0.0.1", "port": 70000},
]:
    try:
        cfg = Config(raw)
        print(f"配置加载成功:{cfg.host}:{cfg.port}")
    except ConfigError as exc:
        print(f"配置加载失败:{exc}")
text
配置加载成功:127.0.0.1:8080
配置加载失败:缺少必要配置项:'port'
配置加载失败:端口超出范围:70000

文件读写与路径处理 ​

python
from pathlib import Path
import json

# Path 是跨平台路径操作的首选
data_dir = Path("output")
data_dir.mkdir(parents=True, exist_ok=True)

file_path = data_dir / "report.json"

# 写:用 with 自动关闭文件;encoding 显式指定,避免跨平台乱码
report = {"total": 3, "items": ["alpha", "beta", "gamma"]}
with file_path.open("w", encoding="utf-8") as f:
    json.dump(report, f, ensure_ascii=False, indent=2)

# 读
with file_path.open(encoding="utf-8") as f:
    loaded = json.load(f)

print(f"写入并读回:{loaded['total']} 项")
print(f"文件是否存在:{file_path.exists()}")
print(f"文件大小:{file_path.stat().st_size} 字节")

# 遍历目录下的所有 Python 文件
for py_file in sorted(data_dir.glob("*.json")):
    print(f"找到:{py_file.name}")
text
写入并读回:3 项
文件是否存在:True
文件大小:93 字节
找到:report.json

实战示例 ​

示例一:完整的日志分析命令行工具 ​

python
# 文件名:log_analyzer.py
import argparse
import re
import sys
from collections import Counter
from pathlib import Path

LOG_PATTERN = re.compile(
    r"^(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+"
    r"(?P<level>[A-Z]+)\s+(?P<message>.+)$"
)

def parse_lines(lines: list[str]) -> tuple[Counter[str], list[str]]:
    """解析日志行,返回级别计数与错误消息列表"""
    level_counter: Counter[str] = Counter()
    errors: list[str] = []

    for line in lines:
        match = LOG_PATTERN.match(line.strip())
        if match is None:                    # 跳过格式不符的行
            continue
        level = match.group("level")
        level_counter[level] += 1
        if level in {"ERROR", "CRITICAL"}:
            errors.append(match.group("message"))
    return level_counter, errors

def analyze(path: Path, top: int) -> int:
    """分析指定日志文件并输出报告"""
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except FileNotFoundError:
        print(f"错误:找不到文件 {path}", file=sys.stderr)
        return 2
    except OSError as exc:
        print(f"错误:读取失败 -> {exc}", file=sys.stderr)
        return 3

    level_counter, errors = parse_lines(lines)

    print(f"=== 日志报告:{path.name} ===")
    print(f"总行数:{len(lines)}")
    print("级别分布:")
    for level, count in level_counter.most_common():
        print(f"  {level:<10}{count:>5}")

    if errors:
        print(f"\n错误 TOP {top}:")
        for message, count in Counter(errors).most_common(top):
            print(f"  {count:>3} 次  {message}")

    # 有 ERROR 时以非零退出码报告异常,便于调度系统告警
    return 1 if level_counter.get("ERROR") else 0

def main() -> int:
    parser = argparse.ArgumentParser(description="日志分析工具")
    parser.add_argument("path", type=Path, help="日志文件路径")
    parser.add_argument("-t", "--top", type=int, default=3, help="错误 Top N")
    args = parser.parse_args()
    return analyze(args.path, args.top)

if __name__ == "__main__":
    raise SystemExit(main())
bash
$ python log_analyzer.py access.log
=== 日志报告:access.log ===
总行数:6
级别分布:
  INFO          3
  ERROR         2
  WARN          1

错误 TOP 3:
    2 次  数据库连接超时
$ echo $?
1

示例二:可复用的模块 + CLI 双形态脚本 ​

python
# 文件名:todo.py
"""极简待办清单:既可 import 作为库,也可直接当命令使用。"""
from __future__ import annotations

import argparse
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path

DEFAULT_DB = Path.home() / ".todo.json"

@dataclass
class Todo:
    """单条待办"""
    title: str
    done: bool = False

class TodoStore:
    """负责待办的持久化与增删改查"""

    def __init__(self, db_path: Path = DEFAULT_DB) -> None:
        self.db_path = db_path
        self.items: list[Todo] = self._load()

    def _load(self) -> list[Todo]:
        if not self.db_path.exists():
            return []
        try:
            raw = json.loads(self.db_path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError) as exc:
            print(f"警告:无法读取数据文件({exc}),将从空清单开始", file=sys.stderr)
            return []
        return [Todo(**item) for item in raw]

    def _save(self) -> None:
        data = [asdict(item) for item in self.items]
        self.db_path.write_text(
            json.dumps(data, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    def add(self, title: str) -> None:
        self.items.append(Todo(title))
        self._save()

    def finish(self, index: int) -> bool:
        """按序号标记完成(序号从 1 开始)"""
        if not 1 <= index <= len(self.items):
            return False
        self.items[index - 1].done = True
        self._save()
        return True

    def pending(self) -> list[Todo]:
        return [item for item in self.items if not item.done]

def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="todo", description="命令行待办清单")
    sub = parser.add_subparsers(dest="command", required=True)

    add_p = sub.add_parser("add", help="添加待办")
    add_p.add_argument("title", help="待办内容")

    done_p = sub.add_parser("done", help="标记完成")
    done_p.add_argument("index", type=int, help="待办序号")

    sub.add_parser("list", help="列出未完成待办")

    args = parser.parse_args(argv)
    store = TodoStore()

    if args.command == "add":
        store.add(args.title)
        print(f"已添加:{args.title}")
    elif args.command == "done":
        ok = store.finish(args.index)
        print("已完成" if ok else f"序号不存在:{args.index}")
    else:
        todos = store.pending()
        if not todos:
            print("暂无待办")
        for i, todo in enumerate(todos, start=1):
            print(f"  {i}. {todo.title}")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
bash
$ python todo.py add "写周报"
已添加:写周报
$ python todo.py add "代码评审"
已添加:代码评审
$ python todo.py list
  1. 写周报
  2. 代码评审
$ python todo.py done 1
已完成
$ python todo.py list
  1. 代码评审

注意事项 ​

  • 入口必须写 if __name__ == "__main__"::否则被导入时会意外执行主逻辑。
  • 文件操作用 with:忘记 close() 会泄漏文件句柄,with 保证异常时也能关闭。
  • 显式指定 encoding="utf-8":否则在 Windows 上默认用 GBK,中文会乱码。
  • 不要裸 except::会吞掉 KeyboardInterrupt,无法用 Ctrl+C 中断。
  • sys.exit() 用于退出:在函数中返回退出码、在入口处 SystemExit 更易测试。
  • 错误信息输出到 stderr:正常结果走 stdout,错误走 stderr,便于管道分离。
  • argparse 的 required=True 子命令:Python 3.7+ 才支持,低版本需手动校验。
  • 不要在导入时做昂贵操作:模块顶层的耗时计算会让 import 变慢,应放进函数。
python
# 错误!导入即执行、无编码声明、无入口保护
# 文件名:bad_script.py
# print("这段代码在 import 时就会执行")
# data = open("data.txt").read()      # Windows 下中文可能乱码
# total = 0
# for i in range(10_000_000):         # 导入时做重计算,拖慢启动
#     total += i

# 正确:全部封装进函数并用入口保护
def summarize() -> int:
    """读取数据并汇总(仅在直接运行时执行)"""
    print("开始处理数据")
    return 0

if __name__ == "__main__":
    raise SystemExit(summarize())
text
开始处理数据

最佳实践 ​

  • 结构固定为「导入 → 常量 → 函数/类 → main() → 入口保护」:读者扫一眼就知道从哪开始。
  • 用 pathlib.Path 代替字符串拼路径:跨平台且支持 / 运算符与方法链。
  • main() 返回退出码而不是直接 exit():把「逻辑」与「进程控制」分离,便于单测。
  • 用 argparse 而非 sys.argv:自动生成 --help、类型转换与错误提示。
  • 异常分层:可预期的错误转成友好提示并返回非零码;不可预期的让它抛出,保留堆栈。
  • 日志用 logging 而非 print:可控制级别、格式与输出位置。
python
import logging

# 日志初始化放在入口,而非模块顶层
def setup_logging(verbose: bool = False) -> None:
    """配置日志:verbose 时输出 DEBUG,否则只输出 INFO 以上"""
    logging.basicConfig(
        level=logging.DEBUG if verbose else logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

def main() -> int:
    setup_logging(verbose=True)
    logging.info("任务开始")
    logging.debug("调试信息:连接参数 host=127.0.0.1")
    logging.warning("磁盘空间不足 10%")
    logging.info("任务结束")
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
text
2026-10-08 10:00:00 [INFO] 任务开始
2026-10-08 10:00:00 [DEBUG] 调试信息:连接参数 host=127.0.0.1
2026-10-08 10:00:00 [WARNING] 磁盘空间不足 10%
2026-10-08 10:00:00 [INFO] 任务结束

下一节

继续学习:Windows 安装 Python,搭建本地开发环境。

参考链接 ​