检查脚本:所有py脚本编码是否正确
check_py_encoding.py 是一个轻量级的 Python 源文件编码检查工具,仅依赖标准库。它会递归扫描指定目录下的所有 .py 文件,检查文件编码是否符合目标编码,默认检查是否为纯 utf-8。
主要功能:
- 递归扫描
.py文件,默认扫描脚本所在目录。 - 支持通过
--encode指定目标编码,如utf-8、gbk等。 - 严格匹配 BOM:
- 目标为
utf-8时,带 UTF-8 BOM 的文件视为不合格; - 目标为
utf-8-sig时,缺少 BOM 的文件视为不合格。
- 目标为
- 自动跳过
.git、__pycache__、.venv、build、node_modules等常见目录。 - 解码失败时,会报告错误字节位置,并尝试识别文件实际编码及 PEP 263 编码声明。
- 输出彩色终端报告,以表格形式列出不符合编码要求的文件、当前编码、问题和路径。
- 退出码清晰:
0全部符合;1存在不符合文件;2参数或路径错误。
用法示例:
bash
python check_py_encoding.py python check_py_encoding.py --encode gbk python check_py_encoding.py path/to/dir python check_py_encoding.py path/to/dir --follow-links
适合在项目开发、提交前检查或 CI 流程中,用于快速确保 Python 源文件编码统一,避免因 BOM、编码不一致等问题引发的解析或兼容性错误。
# coding = utf-8
# Arch = manyArch
#
# @File name: check_py_encoding.py
# @brief: 递归扫描 Python 源代码,检查文件编码是否符合指定编码(默认 utf-8)。
#
# 用法示例:
# python check_py_encoding.py # 检查脚本所在目录下所有 .py 是否为 utf-8
# python check_py_encoding.py --encode gbk # 改为检查 gbk
# python check_py_encoding.py path/to/dir # 指定扫描根目录
#
# BOM 严格匹配:目标为 utf-8 时,带 UTF-8 BOM 的文件会被判为不符合;
# 目标为 utf-8-sig 时,缺少 BOM 的文件会被判为不符合。
#
# 退出码:0 全部符合;1 存在不符合的文件;2 参数或路径错误。
# @attention: None
# @Author: wyb
# @History: 2026-09-11 Create
from __future__ import annotations
import argparse
import codecs
import os
import re
import sys
import unicodedata
from pathlib import Path
# 默认跳过的目录名:版本控制、缓存、虚拟环境、编译产物等
DEFAULT_SKIP_DIRS = {
".git",
".hg",
".svn",
".idea",
".vscode",
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".venv",
"venv",
"env",
"build",
"dist",
"release",
"node_modules",
"site-packages",
}
# 猜测文件真实编码时依次尝试的候选编码(gb18030 兼容 gbk/gb2312,须放在 latin-1 之前)
GUESS_ENCODINGS = ("utf-8", "gb18030", "big5", "shift_jis", "utf-16", "latin-1")
# PEP 263 编码声明(coding cookie)正则,仅匹配文件前两行
CODING_COOKIE_RE = re.compile(rb"^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)")
# 常见 BOM 魔数
UTF8_BOM = b"\xef\xbb\xbf"
UTF16_LE_BOM = b"\xff\xfe"
UTF16_BE_BOM = b"\xfe\xff"
# ═════════════════════════════════════════════════
# ANSI 炫彩工具箱
# ═════════════════════════════════════════════════
class Ansi:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
UNDERLINE = "\033[4m"
BLINK = "\033[5m"
REVERSE = "\033[7m"
BLACK = "\033[30m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
LIGHT_BLACK = "\033[90m"
LIGHT_RED = "\033[91m"
LIGHT_GREEN = "\033[92m"
LIGHT_YELLOW = "\033[93m"
LIGHT_BLUE = "\033[94m"
LIGHT_MAGENTA = "\033[95m"
LIGHT_CYAN = "\033[96m"
LIGHT_WHITE = "\033[97m"
def rainbow_text(text: str) -> str:
"""生成彩虹色文本"""
colors = [196, 208, 220, 46, 21, 33, 45, 51, 87, 129, 165, 201]
out = ""
for i, ch in enumerate(text):
out += f"\033[38;5;{colors[i % len(colors)]}m{ch}"
return out + Ansi.RESET
def iter_py_files(root: Path, skip_dirs: set[str], follow_links: bool = False):
"""递归遍历 root 下的 .py 文件,跳过 skip_dirs 中的目录。"""
for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_links):
dirnames[:] = sorted(d for d in dirnames if d not in skip_dirs)
for name in sorted(filenames):
if name.endswith(".py"):
yield Path(dirpath) / name
def detect_encoding(raw: bytes) -> str:
"""尽力识别文件实际编码,返回可读名称(带 BOM 时注明)。"""
if raw.startswith(UTF8_BOM):
return "utf-8-sig(带BOM)"
if raw.startswith(UTF16_LE_BOM):
return "utf-16-le(带BOM)"
if raw.startswith(UTF16_BE_BOM):
return "utf-16-be(带BOM)"
for enc in GUESS_ENCODINGS:
try:
raw.decode(enc)
return enc
except (UnicodeDecodeError, LookupError):
continue
return "未知"
def declared_encoding(raw: bytes) -> str | None:
"""从文件前两行提取 PEP 263 编码声明,没有则返回 None。"""
for line in raw.splitlines()[:2]:
match = CODING_COOKIE_RE.match(line)
if match:
try:
return match.group(1).decode("ascii")
except UnicodeDecodeError:
return None
return None
def check_bom(raw: bytes, target_name: str) -> str | None:
"""按目标编码严格匹配 BOM:utf-8 不允许带 BOM,utf-8-sig 必须有 BOM。"""
has_utf8_bom = raw.startswith(UTF8_BOM)
if target_name == "utf-8":
if has_utf8_bom:
return "文件带 UTF-8 BOM(ef bb bf),不是纯 utf-8"
elif target_name == "utf-8-sig":
if not has_utf8_bom:
return "缺少 UTF-8 BOM(ef bb bf),不是 utf-8-sig"
return None
def check_file(path: Path, encoding: str, target_name: str) -> tuple[list[str], str]:
"""检查单个文件,返回 (问题描述列表, 检测到的实际编码),无问题时列表为空。"""
try:
raw = path.read_bytes()
except OSError as exc:
return [f"读取失败:{exc}"], "未知"
try:
raw.decode(encoding)
except UnicodeDecodeError as exc:
problems = [f"第 {exc.start} 字节起无法按 {encoding} 解码({exc.reason})"]
declared = declared_encoding(raw)
if declared:
try:
if codecs.lookup(declared).name != codecs.lookup(encoding).name:
problems.append(f"文件声明编码:{declared}")
except LookupError:
problems.append(f"文件声明编码:{declared}(无法识别)")
return problems, detect_encoding(raw)
# 解码虽成功,但按目标编码严格匹配 BOM(utf-8 带 BOM / utf-8-sig 缺 BOM)
bom_problem = check_bom(raw, target_name)
if bom_problem:
return [bom_problem], detect_encoding(raw)
return [], detect_encoding(raw)
def display_path(path: Path, root: Path) -> str:
"""优先显示相对路径,便于阅读。"""
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
# ═════════════════════════════════════════════════
# 显示宽度计算(处理中文等宽字符)
# ═════════════════════════════════════════════════
def display_width(text: str) -> int:
"""返回字符串的显示宽度:ASCII 字符计1,CJK/Emoji 等宽字符计2。"""
w = 0
for ch in text:
cp = ord(ch)
if cp > 0xFFFF: # 补充平面字符(Emoji 等),终端显示宽度通常为 2
w += 2
else:
ea = unicodedata.east_asian_width(ch)
if ea in ("F", "W"): # Fullwidth, Wide (CJK 字符)
w += 2
else:
w += 1
return w
def pad_display(text: str, target_width: int, align_left: bool = True) -> str:
"""按显示宽度填充到目标宽度,align_left=False 时右对齐。"""
cur_w = display_width(text)
if cur_w >= target_width:
return text
padding = target_width - cur_w
if align_left:
return text + " " * padding
else:
return " " * padding + text
def truncate_by_display_width(text: str, max_width: int) -> str:
"""按显示宽度截断字符串,超出部分以 ... 结尾。"""
if display_width(text) <= max_width:
return text
suffix = "..."
suffix_w = display_width(suffix)
if max_width <= suffix_w:
return suffix[:max_width]
remain_w = max_width - suffix_w
result = ""
result_w = 0
for ch in text:
ch_w = 2 if unicodedata.east_asian_width(ch) in ("F", "W") else 1
if result_w + ch_w > remain_w:
break
result += ch
result_w += ch_w
return result + suffix
def truncate_path(path: str, max_width: int) -> str:
"""按显示宽度截断路径,保留尾部并加 .../ 前缀。"""
if display_width(path) <= max_width:
return path
prefix = ".../"
prefix_w = display_width(prefix)
if max_width <= prefix_w:
return path[-max_width:]
remain_w = max_width - prefix_w
suffix = ""
suffix_w = 0
for ch in reversed(path):
ch_w = 2 if unicodedata.east_asian_width(ch) in ("F", "W") else 1
if suffix_w + ch_w > remain_w:
break
suffix = ch + suffix
suffix_w += ch_w
return prefix + suffix
# ═════════════════════════════════════════════════
# UI 绘制(仅着色 + 横线,无框无emoji)
# ═════════════════════════════════════════════════
def print_report_header():
"""标题:上下 ═ 横线夹彩虹字"""
width = 64
print()
print(Ansi.LIGHT_YELLOW + "═" * width + Ansi.RESET)
title = "编 码 检 查 报 告"
title_w = display_width(title)
lpad = (width - title_w) // 2
print(" " * lpad + rainbow_text(title) + " " * max(0, width - title_w - lpad))
print(Ansi.LIGHT_YELLOW + "═" * width + Ansi.RESET)
print()
def print_bad_table_header():
"""表头:加粗 + 下划线,pad_display 对齐"""
header_labels = [
("序号", 4, False),
("当前编码", 18, False),
("问题", 44, True),
("路径", 48, True),
]
parts = [pad_display(label, w, align) for label, w, align in header_labels]
print(Ansi.BOLD + Ansi.UNDERLINE + " " + " ".join(parts) + Ansi.RESET)
def encode_color(detected: str) -> str:
"""按当前编码着色:纯 utf-8 绿、带 BOM 品红、其他(gb18030 等)黄。"""
if "BOM" in detected:
return Ansi.LIGHT_MAGENTA
if detected.startswith("utf-8"):
return Ansi.LIGHT_GREEN
return Ansi.LIGHT_YELLOW
def print_bad_table_row(idx: int, detected: str, problems: str, rel_path: str):
"""数据行:各列着色 + pad_display 对齐"""
col_idx = pad_display(str(idx), 4, align_left=False)
col_det = pad_display(truncate_by_display_width(detected, 18), 18, align_left=False)
col_prob = pad_display(truncate_by_display_width(problems, 44), 44, align_left=True)
col_path = pad_display(truncate_path(rel_path, 48), 48, align_left=True)
print(f" {Ansi.LIGHT_BLACK}{col_idx}{Ansi.RESET} {encode_color(detected)}{col_det}{Ansi.RESET} "
f"{Ansi.LIGHT_WHITE}{col_prob}{Ansi.RESET} {Ansi.LIGHT_CYAN}{col_path}{Ansi.RESET}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="递归检查 Python 源代码文件编码")
parser.add_argument("root", nargs="?", default=None, help="扫描根目录(默认为脚本所在目录)")
parser.add_argument("--encode", default="utf-8", help="目标编码,默认 utf-8")
parser.add_argument("--follow-links", action="store_true", help="跟随符号链接")
args = parser.parse_args(argv)
# 校验编码名是否合法
try:
target_name = codecs.lookup(args.encode).name
except LookupError:
print(f"[错误] 未知编码:{args.encode}", file=sys.stderr)
return 2
root = Path(args.root).resolve() if args.root else Path(__file__).resolve().parent
if not root.is_dir():
print(f"[错误] 目录不存在:{root}", file=sys.stderr)
return 2
bad: list[tuple[Path, str, list[str]]] = []
total = 0
for path in iter_py_files(root, DEFAULT_SKIP_DIRS, args.follow_links):
total += 1
problems, detected = check_file(path, args.encode, target_name)
if problems:
bad.append((path, detected, problems))
print_report_header()
print(f" 扫描目录:{Ansi.LIGHT_CYAN}{root}{Ansi.RESET}")
print(f" 目标编码:{Ansi.LIGHT_YELLOW}{args.encode}({target_name}){Ansi.RESET}")
print()
if bad:
print(Ansi.LIGHT_MAGENTA + Ansi.BOLD + f" ⚠ 不符合 {args.encode} 的文件清单(共 {len(bad)} 个)" + Ansi.RESET)
print(Ansi.LIGHT_BLACK + " " + "─" * 117 + Ansi.RESET)
print_bad_table_header()
for idx, (path, detected, problems) in enumerate(bad, 1):
print_bad_table_row(idx, detected, ";".join(problems), display_path(path, root))
print(Ansi.LIGHT_BLACK + " " + "─" * 117 + Ansi.RESET)
print()
print(f" {Ansi.LIGHT_RED}X 不符合的文件数 : {Ansi.BOLD}{len(bad)}{Ansi.RESET}")
print(f" {Ansi.LIGHT_GREEN}V 扫描文件总数 : {Ansi.BOLD}{total}{Ansi.RESET}")
return 1
print(f" {Ansi.LIGHT_GREEN}V 共扫描 {total} 个 .py 文件,全部符合 {args.encode}。{Ansi.RESET}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
