在 AI 开发者空间实现 JSONL 校验器:原始行号、退出码与 19 项测试
处理日志、问答样本或批量导出数据时,JSONL 很方便:一行放一个 JSON 值,可以追加,也容易按行处理。但文件能打开,并不意味着每一行都适合交给下游程序。少一个右括号、把对象写成数组、遗漏一个字段,都可能让导入任务在中途失败。
这次我在华为云 AI 开发者空间的免费体验环境中,用 AI Shell 完成了一个 Python 标准库小工具。目标很具体:检查每个非空行是否为 JSON 对象、是否包含指定顶层字段,报告原始行号,并用退出码区分数据错误和输入问题。开发过程经历了一次源码检查和修正,最终实际运行了 19 项 unittest 测试。
本文记录的是 2026 年 11 月 6 日这次环境中的实现和输出。示例都是专门创建的合成数据,没有扫描个人文件,也没有部署额外云服务。
1. 先把校验规则写清楚
项目只有一个命令行入口:
python3 jsonl_check.py examples/valid.jsonl --required id,text
规则分为三层。第一层检查 JSON 能否解析,同时拒绝 NaN、Infinity、-Infinity 这些非标准常量;第二层要求解析结果必须是对象;第三层检查命令行指定的顶层字段是否存在。
这里的“存在”按键判断。{"id": 1, "text": null} 包含 text 字段,因此通过;{"id": 1} 没有 text,才会报缺字段。工具不要求 text 一定是字符串,也不递归检查嵌套对象。如果业务要求非空字符串、数值范围或对象之间的关系,需要另加规则。
空白行允许跳过,但仍计入物理行数。错误定位必须对应原文件,不能先删除空行再重新编号。空文件则返回零条记录、零条错误,退出码为 0;“文件必须至少有一条记录”属于另外一项业务约束。
2. 在体验环境中完成一个可验证的小项目
我从 AI Agent 开发实践中心进入 DevSpace,选择免费体验版,在 AI Shell 新建会话,工作目录使用 /root/workspace。随后要求它在独立目录中创建源码、测试、说明和两个样例,并实际执行测试与命令行示例。
最终目录如下,运行需要 Python 3.10 或更新版本:
jsonl-validator-demo/
├── jsonl_check.py
├── test_jsonl_check.py
├── README.md
└── examples/
├── valid.jsonl
└── mixed_errors.jsonl
没有引入第三方库。json 负责解析,argparse 负责参数,unittest 负责测试,测试产生的文件放进 TemporaryDirectory,结束后自动清理。体验环境的数据不长期保留,因此完成后还需要把交付文件和原始输出下载保存。
3. 一行是否合法,拆成一个小函数
Python 的 json.loads 默认允许解析 NaN 等常量。因此只写一个 try/except,并不能满足这里的严格常量规则。实现中通过 parse_constant 指定回调,遇到这些常量立即抛出错误。
下面是实际源码中的单行校验函数:
def _reject_constant(const: str):
raise ValueError(f"Non-standard JSON constant: {const}")
def validate_line(line: str, required_fields: list) -> str | None:
try:
obj = json.loads(line, parse_constant=_reject_constant)
except json.JSONDecodeError as exc:
return f"JSON syntax error: {exc.msg}"
except ValueError as exc:
return str(exc)
if not isinstance(obj, dict):
return f"Not a JSON object (got {type(obj).__name__})"
for field in required_fields:
if field not in obj:
return f"Missing required field: {field}"
return None
返回值只有两类:通过返回 None,失败返回原因字符串。每条错误记录只报告当前发现的第一项问题;如果一行缺少多个字段,不会一次列出所有缺失字段。这种行为简单,也需要在使用时知道它的边界。
4. 测试通过以后,仍然要读源码
第一版的 17 项测试都通过了,实际示例的输出也正确。但检查 run() 时,我发现它先调用了 fh.readlines(),再遍历列表。这样可以按行校验,却会先把整个文件读进内存,与我提出的逐行读取要求不一致。
修正后直接遍历文件对象,把读取和校验放在同一个循环中。以下是实际实现的核心部分,total、blank、valid、err_count 初始值为 0,details 初始为空列表:
try:
with open(path, "r", encoding="utf-8") as fh:
for lineno, raw in enumerate(fh, start=1):
total += 1
stripped = raw.strip()
if not stripped:
blank += 1
continue
reason = validate_line(stripped, required)
if reason is None:
valid += 1
else:
err_count += 1
details.append({"line": lineno, "reason": reason})
except UnicodeDecodeError as exc:
return {"error": f"Bad UTF-8 encoding: {exc}"}, 2
except OSError as exc:
return {"error": f"Cannot read file: {exc}"}, 2
enumerate(fh, start=1) 在遇到空白行之前就确定了物理行号,所以空行不会改变后面错误的位置。try 同时包住打开文件和迭代过程,读取过程中出现编码或 I/O 错误也能返回退出码 2。
逐行读取并不意味着内存恒定。当前行的文本、解析后的对象和错误详情都需要内存;错误列表还会随错误数量增长。特别长的一行仍然需要一次解析。这个版本没有加入行长限制、错误数量上限或分批输出,也没有做大文件性能测试。
5. 用一个七行样例验证错误定位
mixed_errors.jsonl 的实际内容如下,第二行为空行:
{"id": 1, "text": "valid record"}
{"id": 2, "text": "missing closing brace"
[1, 2, 3]
{"id": 5}
{"id": 6, "text": NaN}
{"id": 7, "text": null}
运行 python3 jsonl_check.py examples/mixed_errors.jsonl --required id,text,在体验环境中得到的结果为:
{
"total_lines": 7,
"blank_lines": 1,
"valid_records": 2,
"error_records": 4,
"errors": [
{"line": 3, "reason": "JSON syntax error: Expecting ',' delimiter"},
{"line": 4, "reason": "Not a JSON object (got list)"},
{"line": 5, "reason": "Missing required field: text"},
{"line": 6, "reason": "Non-standard JSON constant: NaN"}
]
}
对应退出码为 1。第七行的 null 被接受,因此有效记录是第 1、7 行;错误仍然落在原文件的第 3、4、5、6 行。把这些数字对照起来,比只看到“校验失败”更容易发现计数和行号方面的问题。
另一个 valid.jsonl 样例共有四行,包含中文文本和 null,实际输出为 total_lines=4、valid_records=4、error_records=0,退出码为 0。给不存在的路径 /no/such/file.jsonl 时,输出 {"error": "File not found: /no/such/file.jsonl"},退出码为 2。
6. 退出码要能被下一个程序使用
这个工具的三种退出码含义如下:
| 退出码 | 含义 | 输出方式 |
|---|---|---|
| 0 | 所有非空行通过校验 | stdout 输出 JSON 汇总 |
| 1 | 至少一条记录不符合规则 | stdout 输出带错误详情的 JSON 汇总 |
| 2 | 文件、编码或命令行参数有问题 | 文件与编码错误输出 JSON;参数错误由 argparse 输出 usage 到 stderr |
文件不存在和数据不合格需要分开处理:前者可能是调用脚本传错了路径,后者才需要修复记录内容。还有一个容易写错的文档细节:不能声称所有错误都输出 JSON。遗漏文件路径这类命令行错误会沿用 argparse 的标准行为,在 stderr 显示用法,stdout 为空。
7. 最终 19 项测试实际通过
在修正读取方式后,我补了两个测试:一个用 mock 让 open 抛出 PermissionError,检查友好错误和退出码;另一个遗漏 CLI 必填位置参数,检查退出码、stderr 用法说明和空 stdout。
这里选 mock 是因为体验环境以 root 身份运行,仅靠 chmod 改权限不能稳定制造“读不了”的场景。它验证的是程序处理异常的分支,不代表已经覆盖所有操作系统和文件系统的权限组合。
最终运行命令与实际输出摘要如下:
python3 -m unittest -v
test_missing_cli_arg ... ok
test_permission_error_mocked ... ok
(其余 17 项也均为 ok)
Ran 19 tests in 0.014s
OK
测试覆盖了中文 UTF-8、空行后的真实行号、JSON 语法错误、非对象值、缺字段与 null、NaN 与 Infinity、空文件、不存在文件、坏编码、多个错误的位置以及三种退出码。这里的 0.014 秒只是这一次小规模测试的运行输出,不能用来推断大文件吞吐量。
8. 使用边界与这次实践的收获
这个版本适合在小型 JSONL 导入前做基础检查,但没有完整 JSON Schema 的类型和结构约束,也不检查重复键。Python 解析重复键时保留后面的值;超深嵌套、超长单行等资源消耗边界也没有专门加固。读取过程不会主动改写输入文件,但不承诺文件系统访问时间等元数据完全不变。
更精确地说,这是一套“允许空白行、对每条记录做对象与字段检查”的约定,不是对所有 JSON Lines 细则的完整认证。源码还会先 strip 行首尾空白,这种宽容处理同样属于本工具的选择。
这次 AI Shell 快速完成了第一版代码和测试,而源码检查找出了测试未覆盖的整文件读取问题。把需求写成可核对的行为,再把真实输出、实现细节和文档对齐,才让这个小工具具备了可复用的基础。下一步若要处理大量错误,可以优先增加错误详情上限或分批输出;在需求出现之前,这次实践先保持现有范围。
- 点赞
- 收藏
- 关注作者
评论(0)