Files
my-v0-server/app/datafiles/detect.py
T

237 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按文件内容识别格式和文本编码,不看扩展名
文本文件整个解码一遍确认编码,但只留开头一段用来判断格式和分隔符;读取时再按确认的编码边读边解码
"""
import codecs
import csv
import json
import re
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.datafiles.base import UNSUPPORTED_FORMAT, DataFileError
from app.schemas.data_file import DataFormat
# xlsx/xlsb 解压后的总大小上限,防止解压炸弹
MAX_UNZIPPED_BYTES = 1024 * 1024 * 1024
# 整体是一个 JSON 值的文件只能整个读进内存解析,峰值内存约为文件大小的 6~7 倍;
# 这个上限让它和最大的 CSV 一样峰值约 1 GB。JSONL 边读边解析,不受这个限制
MAX_JSON_BYTES = 150 * 1024 * 1024
# 带 BOM 的文本直接按 BOM 解码:编码名、跳过 BOM 后的解码方式、读取时的编码(会自己去掉 BOM);
# UTF-32 的 BOM 以 UTF-16 的开头,解出来含 NUL,按二进制拒绝
BOMS = (
(codecs.BOM_UTF8, "utf-8", "utf-8", "utf-8-sig"),
(codecs.BOM_UTF16_LE, "utf-16", "utf-16-le", "utf-16"),
(codecs.BOM_UTF16_BE, "utf-16", "utf-16-be", "utf-16"),
)
# 没有 BOM 时依次尝试;GB18030 兼容 GBK、GB2312,中文 Windows 上 Excel 另存的 CSV 多是这种
FALLBACK_ENCODINGS = ("utf-8", "gb18030")
DELIMITERS = (",", "\t", ";", "|")
# 判断分隔符只看开头这么多行
SNIFF_LINES = 50
# 判断格式和分隔符用的开头文本长度;每次解码读入的字节数
HEAD_CHARS = 1024 * 1024
READ_BYTES = 1024 * 1024
# 开头像 JSON 才按 JSON 处理:对象的 { 后面只能是 " 或 },数组的 [ 后面是一个值的开头;
# CSV 的列名也可能以 [ 开头,例如 [日期],[销售额]
JSON_START = re.compile(r'\{\s*["}]|\[\s*(?:[\[{"\]\d-]|(?:true|false|null)\s*[,\]])')
UNKNOWN_ENCODING = "无法识别文件编码,请另存为 UTF-8 后重新上传"
@dataclass(frozen=True)
class Detected:
format: DataFormat
# 文本格式的编码名(返回给调用方)和读取时传给 open 的编码
encoding: str | None = None
codec: str | None = None
# 文本格式开头的一段
head: str | None = None
# CSV/TSV 的分隔符
delimiter: str | None = None
# JSON 已解析的内容,避免读取时再解析一遍
value: Any = None
@dataclass(frozen=True)
class _Text:
encoding: str
codec: str
head: str
# 开头一段之后还有内容
truncated: bool
# 整个文件只有空白
blank: bool
def detect(path: Path) -> Detected:
with path.open("rb") as file:
head = file.read(8)
if head.startswith(b"PK\x03\x04"):
return Detected(_zip_format(path))
if head.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
return Detected("xls")
if head.startswith(b"PAR1"):
return Detected("parquet")
text = scan_text(path)
if text.blank:
raise DataFileError(422, "文件是空的")
if JSON_START.match(text.head.lstrip()):
return _json_format(path, text)
delimiter = sniff_delimiter(text.head)
return Detected(
"tsv" if delimiter == "\t" else "csv",
text.encoding,
text.codec,
text.head,
delimiter,
)
def _zip_format(path: Path) -> DataFormat:
try:
with zipfile.ZipFile(path) as archive:
infos = archive.infolist()
except zipfile.BadZipFile:
raise DataFileError(415, UNSUPPORTED_FORMAT) from None
if sum(info.file_size for info in infos) > MAX_UNZIPPED_BYTES:
raise DataFileError(413, "Excel 文件解压后过大")
names = {info.filename for info in infos}
if "xl/workbook.xml" in names:
return "xlsx"
if "xl/workbook.bin" in names:
return "xlsb"
raise DataFileError(415, UNSUPPORTED_FORMAT)
def scan_text(path: Path) -> _Text:
"""确认文本编码:BOM 优先,否则依次严格尝试 FALLBACK_ENCODINGS"""
with path.open("rb") as file:
start = file.read(4)
for bom, name, decoding, codec in BOMS:
if start.startswith(bom):
try:
return _decode(path, name, decoding, codec, len(bom))
except UnicodeDecodeError:
raise DataFileError(422, UNKNOWN_ENCODING) from None
for encoding in FALLBACK_ENCODINGS:
try:
return _decode(path, encoding, encoding, encoding, 0)
except UnicodeDecodeError:
continue
raise DataFileError(422, UNKNOWN_ENCODING)
def _decode(path: Path, name: str, decoding: str, codec: str, skip: int) -> _Text:
"""严格解码整个文件,留下开头一段;含 NUL 时按二进制文件拒绝"""
decoder = codecs.getincrementaldecoder(decoding)("strict")
parts: list[str] = []
kept, blank, truncated = 0, True, False
with path.open("rb") as file:
file.seek(skip)
while True:
chunk = file.read(READ_BYTES)
text = decoder.decode(chunk, final=not chunk)
# 文本文件不会有 NUL;图片、PDF 等二进制文件偶尔能按 GB18030 解码成功,在这里拦下
if "\x00" in text:
raise DataFileError(415, UNSUPPORTED_FORMAT)
blank = blank and not text.strip()
if kept < HEAD_CHARS:
parts.append(text)
kept += len(text)
elif text:
truncated = True
if not chunk:
break
head = "".join(parts)
if len(head) > HEAD_CHARS:
head, truncated = head[:HEAD_CHARS], True
cut = head.rfind("\n")
if truncated and cut >= 0:
# 截断处可能在一行中间,丢掉最后半行
head = head[: cut + 1]
return _Text(name, codec, head, truncated, blank)
def _json_format(path: Path, text: _Text) -> Detected:
if _first_line_is_record(text):
return Detected("jsonl", text.encoding, text.codec, text.head)
# 整体是一个 JSON 值,只能整个读进来解析
if path.stat().st_size > MAX_JSON_BYTES:
raise DataFileError(
413,
f"JSON 文件不能超过 {MAX_JSON_BYTES // 1024 // 1024} MB,"
"请转成 JSONL(每行一个 JSON 对象)后上传",
)
content = path.read_bytes().decode(text.codec)
try:
return Detected(
"json", text.encoding, text.codec, text.head, value=json.loads(content)
)
except ValueError as error:
json_error = error
first = next(line for line in content.splitlines() if line.strip())
try:
if isinstance(json.loads(first), dict):
return Detected("jsonl", text.encoding, text.codec, text.head)
except ValueError:
pass
# 开头像 JSON 却不是合法 JSON,按 JSON 报错比当成 CSV 解析出一堆乱码更有用
line = getattr(json_error, "lineno", None)
position = f"第 {line} 行" if line else ""
raise DataFileError(
422, f"JSON 格式错误:{position}{getattr(json_error, 'msg', json_error)}"
)
def _first_line_is_record(text: _Text) -> bool:
"""第一行是完整的 JSON 对象、后面还有内容:整体不可能是一个 JSON 值,是 JSONL
不用整个解析就能判断,大 JSONL 文件不必整个读进内存
"""
lines = text.head.splitlines(keepends=True)
index = next((i for i, line in enumerate(lines) if line.strip()), None)
if index is None:
return False
first, rest = lines[index], lines[index + 1 :]
if first == first.rstrip("\r\n") and (rest or text.truncated):
# 第一行在开头一段里没有结束
return False
if not (text.truncated or any(line.strip() for line in rest)):
return False
try:
return isinstance(json.loads(first), dict)
except ValueError:
return False
def sniff_delimiter(text: str) -> str:
"""选出让开头各行列数最一致、列数最多的分隔符;都只有一列时按逗号处理"""
lines = []
for line in text.splitlines():
if line.strip():
lines.append(line)
if len(lines) == SNIFF_LINES:
break
best, best_score = ",", (0.0, 1)
for delimiter in DELIMITERS:
widths = [len(row) for row in csv.reader(lines, delimiter=delimiter)]
width = max(set(widths), key=widths.count)
if width < 2:
continue
score = (widths.count(width) / len(widths), width)
if score > best_score:
best, best_score = delimiter, score
return best