已开启
fix: 修复 check-json/check-yaml 误杀合法文件及错误报告问题 #2
liruichen创建于 6月25日
fix: 修复 check-json/check-yaml 误杀合法文件及错误报告问题 #2
已开启
共 5 个文件变更+240-11
| @@ -91,22 +91,25 @@ | |||
| 91 | additional_dependencies: ['autopep8==2.3.1'] | 91 | additional_dependencies: ['autopep8==2.3.1'] |
| 92 | minimum_pre_commit_version: '2.9.2' | 92 | minimum_pre_commit_version: '2.9.2' |
| 93 | 93 | ||
| 94 | -# === JSON / YAML 语法检查(inline entry,无脚本) === | 94 | +# === JSON / YAML 语法检查 === |
| 95 | -# 参考 litebmc/pre-commit-hooks:entry 直接内联 python -c,避免新增脚本文件。 | 95 | +# check-json 落地为 hooks/check_json.py(language: script,纯标准库):可剥除 |
| 96 | -# check-yaml 依赖 pyyaml,由 additional_dependencies 自动安装;check-json 仅用标准库。 | 96 | +# UTF-8 BOM、逐文件报告错误并在首个坏文件后继续检查(对齐 check-sr 范式)。 |
| 97 | +# check-yaml 依赖 pyyaml,故保持 inline entry + language: python,由 | ||
| 98 | +# additional_dependencies 自动安装;safe_load_all 支持多文档 YAML,open 'rb' | ||
| 99 | +# 交由 PyYAML 做编码自检/剥 BOM,且解析错误消息自带文件名。 | ||
| 97 | 100 | ||
| 98 | - id: check-json | 101 | - id: check-json |
| 99 | name: Check JSON | 102 | name: Check JSON |
| 100 | - description: JSON 语法检查 | 103 | + description: JSON 语法检查(自动剥除 UTF-8 BOM,逐文件报告错误) |
| 101 | - entry: python -c "import sys, json; [json.load(open(f, encoding='utf-8')) for f in sys.argv[1:]]" | 104 | + entry: hooks/check_json.py |
| 102 | - language: python | 105 | + language: script |
| 103 | files: \.json$ | 106 | files: \.json$ |
| 104 | - minimum_pre_commit_version: '2.9.2' | 107 | + minimum_pre_commit_version: '3.0.0' |
| 105 | 108 | ||
| 106 | - id: check-yaml | 109 | - id: check-yaml |
| 107 | name: Check YAML | 110 | name: Check YAML |
| 108 | - description: YAML 语法检查 (yaml.safe_load) | 111 | + description: YAML 语法检查(safe_load_all 支持多文档,自动剥除 BOM) |
| 109 | - entry: python -c "import sys, yaml; [yaml.safe_load(open(f, encoding='utf-8')) for f in sys.argv[1:]]" | 112 | + entry: python -c "import sys, yaml; [list(yaml.safe_load_all(open(f, 'rb'))) for f in sys.argv[1:]]" |
| 110 | language: python | 113 | language: python |
| 111 | files: \.(yaml|yml)$ | 114 | files: \.(yaml|yml)$ |
| 112 | additional_dependencies: ['pyyaml==6.0.2'] | 115 | additional_dependencies: ['pyyaml==6.0.2'] |
| @@ -19,8 +19,8 @@ openUBMC 专属钩子(conventional-commit / add-signoff-and-change-id / check- | |||
| 19 | | conventional-commit | 校验提交信息符合 Conventional Commits 规范 | commit-msg | 否 | | 19 | | conventional-commit | 校验提交信息符合 Conventional Commits 规范 | commit-msg | 否 | |
| 20 | | add-signoff-and-change-id | 在 commit message 末尾追加 Signed-off-by 和 Change-Id | commit-msg | 是 | | 20 | | add-signoff-and-change-id | 在 commit message 末尾追加 Signed-off-by 和 Change-Id | commit-msg | 是 | |
| 21 | | check-sr | 校验 .sr 文件语法(支持 C 风格注释) | pre-commit | 否 | | 21 | | check-sr | 校验 .sr 文件语法(支持 C 风格注释) | pre-commit | 否 | |
| 22 | -| check-json | JSON 语法检查(标准库) | pre-commit | 否 | | 22 | +| check-json | JSON 语法检查(标准库,剥 BOM、逐文件报告) | pre-commit | 否 | |
| 23 | -| check-yaml | YAML 语法检查(pyyaml) | pre-commit | 否 | | 23 | +| check-yaml | YAML 语法检查(pyyaml,支持多文档、剥 BOM) | pre-commit | 否 | |
| 24 | | black | Python 代码格式化检查(line-length=120) | pre-commit | 否 | | 24 | | black | Python 代码格式化检查(line-length=120) | pre-commit | 否 | |
| 25 | | isort | Python import 排序检查(profile=black) | pre-commit | 否 | | 25 | | isort | Python import 排序检查(profile=black) | pre-commit | 否 | |
| 26 | | flake8 | Python 代码风格检查(忽略 E203/W503) | pre-commit | 否 | | 26 | | flake8 | Python 代码风格检查(忽略 E203/W503) | pre-commit | 否 | |
| @@ -0,0 +1,48 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# encoding=utf-8 | ||
| 3 | +# 描述:校验 JSON 文件语法(自动剥除 UTF-8 BOM) | ||
| 4 | +# Copyright (c) 2024 Huawei Technologies Co., Ltd. | ||
| 5 | +# openUBMC is licensed under Mulan PSL v2. | ||
| 6 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 7 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 8 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 9 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 10 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 11 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 12 | +# See the Mulan PSL v2 for more details. | ||
| 13 | +""" | ||
| 14 | +Validate JSON files. | ||
| 15 | + | ||
| 16 | +Reads each file as UTF-8 (a leading BOM is auto-stripped via utf-8-sig) and | ||
| 17 | +parses it with ``json.load``. Pure standard library — no third-party deps. | ||
| 18 | + | ||
| 19 | +Relative to a naive inline ``json.load(open(f))`` this hook: | ||
| 20 | +- tolerates a leading UTF-8 BOM (valid per RFC 8259 §8.1), | ||
| 21 | +- prints the offending file path on error (``<path>: <reason>``), | ||
| 22 | +- keeps checking the remaining files instead of bailing on the first failure. | ||
| 23 | + | ||
| 24 | +Usage: check_json.py <file1.json> [file2.json ...] | ||
| 25 | +""" | ||
| 26 | +import json | ||
| 27 | +import sys | ||
| 28 | +from typing import Sequence | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def main(argv: Sequence[str] | None = None) -> int: | ||
| 32 | + if argv is None: | ||
| 33 | + argv = sys.argv[1:] | ||
| 34 | + if not argv: | ||
| 35 | + return 0 | ||
| 36 | + retval = 0 | ||
| 37 | + for path in argv: | ||
| 38 | + try: | ||
| 39 | + with open(path, encoding="utf-8-sig") as f: | ||
| 40 | + json.load(f) | ||
| 41 | + except (OSError, ValueError) as e: | ||
| 42 | + print(f"{path}: {e}") | ||
| 43 | + retval = 1 | ||
| 44 | + return retval | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +if __name__ == "__main__": | ||
| 48 | + raise SystemExit(main()) | ||
| @@ -0,0 +1,91 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# encoding=utf-8 | ||
| 3 | +# 描述:check_json.py 钩子的单元测试 | ||
| 4 | +# Copyright (c) 2024 Huawei Technologies Co., Ltd. | ||
| 5 | +# openUBMC is licensed under Mulan PSL v2. | ||
| 6 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 7 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 8 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 9 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 10 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 11 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 12 | +# See the Mulan PSL v2 for more details. | ||
| 13 | +from __future__ import annotations | ||
| 14 | + | ||
| 15 | +import pytest | ||
| 16 | + | ||
| 17 | +from hooks.check_json import main | ||
| 18 | + | ||
| 19 | + | ||
| 20 | + | ||
| 21 | + ("content", "expected_retval"), | ||
| 22 | + ( | ||
| 23 | + # --- 合法(retval=0)--- | ||
| 24 | + ('{"a": 1}\n', 0), # 纯对象 | ||
| 25 | + ('{"a": 1, "b": [2, 3]}\n', 0), # 嵌套数组 | ||
| 26 | + ('[1, 2, 3]\n', 0), # 顶层数组 | ||
| 27 | + (' {\n "a": 1\n}\n', 0), # 带缩进/换行/空白 | ||
| 28 | + ('{"url": "https://example.com"}\n', 0), # 值内的 //(无注释语义,正常) | ||
| 29 | + ('{"name": "中文"}\n', 0), # 非 ASCII(UTF-8 直接编码) | ||
| 30 | + ('"just a string"\n', 0), # 顶层字符串 | ||
| 31 | + ('123\n', 0), # 顶层数字 | ||
| 32 | + ('null\n', 0), # 顶层 null | ||
| 33 | + # --- 非法(retval=1)--- | ||
| 34 | + ('{"a": 1,}\n', 1), # 尾逗号 | ||
| 35 | + ("{'a': 1}\n", 1), # 单引号 | ||
| 36 | + ("{a: 1}\n", 1), # 裸 key | ||
| 37 | + ('{"a": }\n', 1), # 缺值 | ||
| 38 | + ('{"a": 1\n', 1), # 未闭合 } | ||
| 39 | + ("", 1), # 空文件 | ||
| 40 | + ('{"a": "hello}\n', 1), # 未闭合字符串 | ||
| 41 | + ), | ||
| 42 | +) | ||
| 43 | +def test_main(tmpdir, capsys, content, expected_retval): | ||
| 44 | + f = tmpdir.join("t.json") | ||
| 45 | + # encode explicitly so Windows (GBK default) doesn't mangle non-ASCII test data | ||
| 46 | + f.write_binary(content.encode("utf-8")) | ||
| 47 | + ret = main((str(f),)) | ||
| 48 | + assert ret == expected_retval | ||
| 49 | + if expected_retval == 1: | ||
| 50 | + stdout, _ = capsys.readouterr() | ||
| 51 | + assert str(f) in stdout | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def test_main_bom(tmpdir): | ||
| 55 | + """UTF-8 BOM 应被 utf-8-sig 自动剥除,校验通过(Bug 1 回归)。""" | ||
| 56 | + f = tmpdir.join("bom.json") | ||
| 57 | + f.write_binary(b'\xef\xbb\xbf{"a": 1}\n') | ||
| 58 | + assert main((str(f),)) == 0 | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +def test_main_unicode_content(tmpdir): | ||
| 62 | + """非 ASCII 内容(直接 UTF-8 编码,非转义)。""" | ||
| 63 | + f = tmpdir.join("u.json") | ||
| 64 | + f.write_binary('{"name": "中文"}\n'.encode("utf-8")) | ||
| 65 | + assert main((str(f),)) == 0 | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +def test_main_nonexistent_file(capsys): | ||
| 69 | + """不存在的文件:OSError 被捕获,retval=1,文件名出现在输出。""" | ||
| 70 | + ret = main(["/no/such/file.json"]) | ||
| 71 | + assert ret == 1 | ||
| 72 | + stdout, _ = capsys.readouterr() | ||
| 73 | + assert "/no/such/file.json" in stdout | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +def test_main_no_args(): | ||
| 77 | + """空 argv:直接返回 0,不抛错。""" | ||
| 78 | + assert main([]) == 0 | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def test_main_multiple_files_one_bad(tmpdir, capsys): | ||
| 82 | + """多文件混合:坏文件不阻断后续检查,整体 retval=1,坏文件名出现、好文件名不出现(问题 3)。""" | ||
| 83 | + good = tmpdir.join("good.json") | ||
| 84 | + good.write('{"a": 1}\n') | ||
| 85 | + bad = tmpdir.join("bad.json") | ||
| 86 | + bad.write('{"a": ,}\n') | ||
| 87 | + ret = main([str(good), str(bad)]) | ||
| 88 | + assert ret == 1 | ||
| 89 | + stdout, _ = capsys.readouterr() | ||
| 90 | + assert str(bad) in stdout | ||
| 91 | + assert str(good) not in stdout | ||
| @@ -0,0 +1,87 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# encoding=utf-8 | ||
| 3 | +# 描述:check-yaml 钩子的单元测试(通过 subprocess 执行 inline entry) | ||
| 4 | +# Copyright (c) 2024 Huawei Technologies Co., Ltd. | ||
| 5 | +# openUBMC is licensed under Mulan PSL v2. | ||
| 6 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 7 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 8 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 9 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 10 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 11 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 12 | +# See the Mulan PSL v2 for more details. | ||
| 13 | +from __future__ import annotations | ||
| 14 | + | ||
| 15 | +import os | ||
| 16 | +import subprocess | ||
| 17 | +import sys | ||
| 18 | + | ||
| 19 | +import pytest | ||
| 20 | + | ||
| 21 | +# check-yaml 依赖 pyyaml:测试环境若未安装则整体跳过(requirements-dev 仅含 pytest)。 | ||
| 22 | +pytest.importorskip("yaml") | ||
| 23 | + | ||
| 24 | +# 必须与 .pre-commit-hooks.yaml 中 check-yaml 的 entry 代码保持一致。 | ||
| 25 | +CODE = "import sys, yaml; [list(yaml.safe_load_all(open(f, 'rb'))) for f in sys.argv[1:]]" | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def run(*files: str) -> subprocess.CompletedProcess: | ||
| 29 | + """以 [python, -c, CODE, *files] 执行 inline entry,等价于 pre-commit 的调用方式。""" | ||
| 30 | + return subprocess.run( | ||
| 31 | + [sys.executable, "-c", CODE, *files], | ||
| 32 | + capture_output=True, | ||
| 33 | + text=True, | ||
| 34 | + ) | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def _write(tmpdir, name: str, content: str): | ||
| 38 | + f = tmpdir.join(name) | ||
| 39 | + f.write_binary(content.encode("utf-8")) | ||
| 40 | + return str(f) | ||
| 41 | + | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + ("content", "should_pass"), | ||
| 45 | + ( | ||
| 46 | + ("a: 1\n", True), # 单文档 | ||
| 47 | + ("a: 1\n---\nb: 2\n", True), # 合法多文档(Bug 2 回归:旧实现会误报) | ||
| 48 | + ("{\"a\": 1}\n", True), # flow mapping | ||
| 49 | + ("- a\n- b\n", True), # 序列 | ||
| 50 | + ("a: [unclosed\n", False), # 语法错误 | ||
| 51 | + ("a: 1\n---\nb: [unclosed", False), # 多文档,第 2 个文档语法错误(旧实现根本不检查) | ||
| 52 | + ("key: value: oops\n", False), # 映射值非法 | ||
| 53 | + ), | ||
| 54 | +) | ||
| 55 | +def test_entry(tmpdir, content, should_pass): | ||
| 56 | + f = _write(tmpdir, "t.yaml", content) | ||
| 57 | + result = run(f) | ||
| 58 | + assert (result.returncode == 0) is should_pass | ||
| 59 | + if not should_pass: | ||
| 60 | + # 问题 3:错误消息自带文件名 | ||
| 61 | + assert f in (result.stdout + result.stderr) | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def test_entry_bom(tmpdir): | ||
| 65 | + """UTF-8 BOM 应被 PyYAML 自动剥除,校验通过。""" | ||
| 66 | + f = tmpdir.join("bom.yaml") | ||
| 67 | + f.write_binary(b"\xef\xbb\xbfkey: val\n") | ||
| 68 | + assert run(str(f)).returncode == 0 | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +def test_entry_nonexistent_file(tmpdir): | ||
| 72 | + """不存在的文件:非零退出,错误自带文件名。""" | ||
| 73 | + missing = str(tmpdir.join("nope.yaml")) | ||
| 74 | + result = run(missing) | ||
| 75 | + assert result.returncode != 0 | ||
| 76 | + # Python 3.12+ 的 FileNotFoundError 在 traceback 中用 repr 显示文件名(反斜杠转义), | ||
| 77 | + # 故以 basename 断言文件名可见,与实际 pre-commit 输出一致。 | ||
| 78 | + assert os.path.basename(missing) in (result.stdout + result.stderr) | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def test_entry_multiple_files_one_bad(tmpdir): | ||
| 82 | + """多文件混合:坏文件触发非零退出且文件名出现在输出。""" | ||
| 83 | + good = _write(tmpdir, "good.yaml", "a: 1\n") | ||
| 84 | + bad = _write(tmpdir, "bad.yaml", "a: [unclosed\n") | ||
| 85 | + result = run(good, bad) | ||
| 86 | + assert result.returncode != 0 | ||
| 87 | + assert bad in (result.stdout + result.stderr) | ||