编写完整脚本
前面几节学习的语法片段,最终都要组装成可以独立运行的程序。一个「完整的脚本」不只是能跑通,还应该:接收命令行参数、处理异常、输出日志、返回正确的退出码、便于他人复用。本节将从零写一个命令行工具,讲清 __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,搭建本地开发环境。