你能用一个朴素的 try-except 块安全地读取 utf8 和 latin1 文件吗?

Can you safely read utf8 and latin1 files with a naïve try-except block?

提问人:Simon Streicher 提问时间:11/1/2022 最后编辑:Simon Streicher 更新时间:11/2/2022 访问量:592

问:

我相信任何有效的 latin1 字符都会被 Python 的 utf8 编码器正确解释或抛出错误。因此,我声称,如果您只使用 utf8 文件或 latin1 文件,您可以安全地编写以下代码来读取这些文件,而不会以 Mojibake 结束:

from pathlib import Path

def read_utf8_or_latin1_text(path: Path, args, kwargs):
    try:
        return path.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        return path.read_text(encoding="latin1")

在这个大型字符数据集上测试了这个假设,发现它经得起推敲。情况总是如此吗?

输入:

import requests

insanely_many_characters = requests.get(
    "https://github.com/bits/UTF-8-Unicode-Test-Documents/raw/master/UTF-8_sequence_unseparated/utf8_sequence_0-0x10ffff_including-unassigned_including-unprintable-asis_unseparated.txt"
).text


print(
    f"\n=== test {len(insanely_many_characters)} utf-8 characters for same-same misinterpretations ==="
)
for char in insanely_many_characters:
    if (x := char.encode("utf-8").decode("utf-8")) != char:
        print(char, x)


print(
    f"\n=== test {len(insanely_many_characters)} latin1 characters for same-same misinterpretations ==="
)
latinable = []
nr = 0
for char in insanely_many_characters:
    try:
        if (x := char.encode("latin1").decode("latin1")) != char:
            print(char, x)
        latinable.append(char)
    except UnicodeEncodeError:
        nr += 1
if nr:
    print(f"{nr} characters not in latin1 set")

print('found the following valid latin1 characters: """\n' + "".join(latinable) + '\n"""')

print(
    f"\n=== test {len(latinable)} latin1 characters for utf-8 Mojibake ==="
)
for char in latinable:
    try:
        if (x := char.encode("latin1").decode("utf-8")) != char:
            print(char, x)
    except UnicodeDecodeError:
        pass

输出:


=== test 1111998 utf-8 characters for same-same misinterpretations ===

=== test 1111998 latin1 characters for same-same misinterpretations ===
1111742 characters not in latin1 set
found the following latin1 characters: """
    
 !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ
"""

=== test 256 latin1 characters for utf-8 Mojibake ===

补遗:

我看到我完全忘记了测试 latin1 字符的序列,而只测试了单个字符。通过添加此测试:

print(
    f"\n=== test {len(latinable)} latin1 sequences wrongly interoperable by utf-8 ==="
)
for char1 in latinable:
    for char2 in latinable:
        try:
            if (x := (char1 + char2).encode("latin1").decode("utf-8")) != char1 + char2:
                print(char1 + char2, x)
        except UnicodeDecodeError:
            pass

我最终生成了许多 utf-8 Mojibake(总共 1920 个实例),这是我假设的反例!

=== test 256 latin1 sequences wrongly interoperable by utf-8 ===
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
   
¡ ¡

 ⋮
蟒蛇 UTF-8 ISO-8859-1

评论


答:

3赞 tripleee 11/1/2022 #1

从理论上讲,存在有效的 Latin-1 序列,它们也是有效的 UTF-8 序列。在实践中,这些在任何真实世界的文本数据中都不太可能。

您可以轻松创建可能组合的表格;这是一个样品供品尝。

>>> for x in "\u00a0\u00a1\u00a2\u00ff\u1234\U00012345":
...   print(x.encode('utf-8').decode('latin-1'))
... 
 
¡
¢
ÿ
á´
ð

(后面有一些空格字符。ð

一个重要的极端情况可能是包含mojibake的文件;几乎不可能设计出一种启发式方法,在面对人类愚蠢的聪明才智的所有方面都能正确工作。如果您将 Latin-1 与偶尔来自 UTF-8 的 möjibäké 混合在一起,则朴素的算法会得出结论,整个文件必须是 Latin-1。(另一方面,如果整个文件都受到影响,它实际上会为您解决问题。

评论

0赞 Simon Streicher 11/1/2022
这是有道理的,谢谢,我扫描了所有 Mojibake 生成两个拉丁字符的序列,没有什么看起来像是自然出现在英语(或拉丁语)和数字类型文本文件中的字符,例如典型的 csv 文件。这显然仍然是可能的,但对于英语/数字 csv 文件或英语/数字 JSON 文件之类的东西可能足够好(如果你足够了解你的源数据)。
0赞 ShadowRanger 11/1/2022
@SimonStreicher:在 Windows 上,一个常见的解决方案是在写入文本数据时使用编码,而不是 .它是非标准的(BOM 用于 UTF-16 和 UTF-32 以指示字节序,这不适用于 UTF-8),但它有助于直接嗅探文件类型(仅读取三个字节以检查 BOM 的 UTF-8 等效物),而无需启发式确定它是否是 UTF-8。大多数 Windows(或至少是 Microsoft)应用程序识别并接受(并且可以生成)UTF-8 BOM;在类 UNIX 方面,支持是零星的。utf-8-sigutf-8
0赞 tripleee 11/1/2022
在 Unix 上使用 BOM 通常不受欢迎,因为它会导致互操作性问题 - 文本文件应该只包含文本,而不是元数据。在许多系统上,UTF-8 非常普遍,因此几乎没有理由将任何内容显式标记为 UTF-8。
0赞 Simon Streicher 11/2/2022
谢谢,我们的问题是我们无法控制的文件。在写作时,我们使用(除非量身定制)。我们聚合和合并(和转换)来自各种遗留系统的数据,并为我们的非开发人员提供直接在客户处使用的工具。文件编码只是经常出现的问题之一,对于非开发人员来说很难理解(也很难检测和规避)。我正在考虑添加一个选项,然后添加一些这些启发式方法,以确保可能好的编码器⊂ {, }(在 4 年中我只见过 / 在客户那里)utf-8encoding="auto"utf-8latin1utf-8latin1
3赞 ShadowRanger 11/1/2022 #2

你错了。编码是每个字节的 1-1 映射。巧合的是,有效编码完全有可能包含解码为不同 UTF-8 字符的字节。数据中存在的非 ASCII 字符越多,发生此类事情的几率就越低,但它可能会发生。latin-1latin-1

您的测试没有找到这些情况,因为根据定义,至少需要两个编码为 latin-1 的字符才能生成有效但不匹配的 UTF-8 中单个字符的编码(编码为 UTF-8 的非 ASCII 长度始终为 2-4 个字节,绝不只是一个字节;ASCII 在 latin-1 和 UTF-8 中的编码相同)。由于您只测试了将单个字符编码为 latin-1,因此它不可能生成合法的 UTF-8 表示形式,但高于 ASCII 范围的许多 latin-1 字符对(或三元组或四元组)将巧合地生成合法的 UTF-8 字节。它们可能会造成完全的垃圾,但它们会有效解码。

3赞 Serge Ballesta 11/1/2022 #3

您最初的假设是正确的:如果一个只能是 latin1 编码的 utf-8 的文件不能被读取为 utf-8,那么它就是 latin1 编码的。事实是,任何字节序列都可以解码为 latin1,因为 latin1 编码是 256 个可能的字节和 [0;256[ 范围。

但是你的测试如果完全不同。将有效的 utf-8 编码文件加载为 unicode 字符,然后测试 latin1 中存在哪些 unicode 字符,发现只有 256 个字符排在第一位。

换句话说,问题和代码只是松散地相关......

评论

0赞 Simon Streicher 11/1/2022
我同意由于几个原因,测试是不完整的。我认为找到 256 个 latin1 字符不是错误(因为它被记录为只有 256 个字符,它让我进入了那个集合),它没有测试 utf-1 Mojibake 的 latin8 字符序列(因此没有找到任何反例)。
0赞 Simon Streicher 11/1/2022
The fact is that any sequence of bytes can be decoded as latin1是我的假设的一个很好的理论反驳,谢谢。因为这意味着任何有效的 utf-8 文件理论上都可以是所需的 latin1 文件。