12 votes

Python 3 UnicodeDecodeError - Comment déboguer UnicodeDecodeError ?

Je dispose d'un fichier texte dont l'éditeur (la Commission des opérations de bourse des États-Unis) affirme qu'il est codé en UTF-8 ( https://www.sec.gov/files/aqfs.pdf section 4). Je traite les lignes avec le code suivant :

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with codecs.open(filename, 'r', encoding='utf-8', errors='strict') as f:
        fields = f.readline().strip().split('\t')
        for line in f.readlines():
            yield process_tag_record(fields, line)

Je reçois l'erreur suivante :

Traceback (most recent call last):
  File "/home/randm/Projects/finance/secxbrl.py", line 151, in <module>
    main()
  File "/home/randm/Projects/finance/secxbrl.py", line 143, in main
    all_tags = list(tags("tag.txt"))
  File "/home/randm/Projects/finance/secxbrl.py", line 109, in tags
    content = f.read()
  File "/home/randm/Libraries/anaconda3/lib/python3.6/codecs.py", line 698, in read
    return self.reader.read(size)
  File "/home/randm/Libraries/anaconda3/lib/python3.6/codecs.py", line 501, in read
    newchars, decodedbytes = self.decode(data, self.errors)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xad in position 3583587: invalid start byte

Étant donné que je ne peux probablement pas retourner à la SEC et lui dire qu'elle a des fichiers qui ne semblent pas être encodés en UTF-8, comment dois-je déboguer et attraper cette erreur ?

Ce que j'ai essayé

J'ai fait un hexdump du fichier et j'ai trouvé que le texte incriminé était le texte "SUPPLEMENTAL DISCLOSURE OF NON�CASH INVESTING". Si je décode l'octet incriminé en tant que point de code hexadécimal (c'est-à-dire "U+00AD"), cela a du sens dans le contexte puisqu'il s'agit du tiret doux. Mais ce qui suit ne semble pas fonctionner :

Python 3.5.2 (default, Nov 17 2016, 17:05:23) 
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> b"\x41".decode("utf-8")
'A'
>>> b"\xad".decode("utf-8")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec cant decode byte 0xad in position 0: invalid start byte
>>> b"\xc2ad".decode("utf-8")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec cant decode byte 0xc2 in position 0: invalid continuation byte

J'ai utilisé errors='replace' qui semble passer. Mais j'aimerais comprendre ce qui va se passer si j'essaie d'insérer cela dans une base de données.

Modifié pour ajouter hexdump :

0036ae40  31 09 09 09 09 53 55 50  50 4c 45 4d 45 4e 54 41  |1....SUPPLEMENTA|
0036ae50  4c 20 44 49 53 43 4c 4f  53 55 52 45 20 4f 46 20  |L DISCLOSURE OF |
0036ae60  4e 4f 4e ad 43 41 53 48  20 49 4e 56 45 53 54 49  |NON.CASH INVESTI|
0036ae70  4e 47 20 41 4e 44 20 46  49 4e 41 4e 43 49 4e 47  |NG AND FINANCING|
0036ae80  20 41 43 54 49 56 49 54  49 45 53 3a 09 0a 50 72  | ACTIVITIES:..Pr|

11voto

Martijn Pieters Points 271458

Vous avez un fichier de données corrompu. Si ce personnage est vraiment destiné à être un U+00AD TIRET DOUX alors il vous manque un octet 0xC2 :

>>> '\u00ad'.encode('utf8')
b'\xc2\xad'

Parmi tous les codages UTF-8 possibles qui se terminent par 0xAD, le tiret doux est celui qui a le plus de sens. Cependant, il indique un ensemble de données qui mai ont d'autres octets manquants. Tu viens juste d'en trouver un qui compte.

Je retournerais à la source de cet ensemble de données et vérifierais que le fichier n'a pas été corrompu lors du téléchargement. Sinon, l'utilisation de error='replace' est une solution de contournement viable, à condition qu'aucun délimiteur (tabulation, saut de ligne, etc.) ne manque.

Une autre possibilité est que la SEC utilise en réalité un différents le codage du fichier ; par exemple dans Windows Codepage 1252 et Latin-1, 0xAD est l'encodage correct d'un tiret doux. Et en effet, lorsque je télécharge le même ensemble de données directement (avertissement, gros fichier ZIP lié) et ouvert tags.txt Je ne peux pas décoder les données en UTF-8 :

>>> open('/tmp/2017q1/tag.txt', encoding='utf8').read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../lib/python3.6/codecs.py", line 321, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xad in position 3583587: invalid start byte
>>> from pprint import pprint
>>> f = open('/tmp/2017q1/tag.txt', 'rb')
>>> f.seek(3583550)
3583550
>>> pprint(f.read(100))
(b'1\t1\t\t\t\tSUPPLEMENTAL DISCLOSURE OF NON\xadCASH INVESTING AND FINANCING A'
 b'CTIVITIES:\t\nProceedsFromSaleOfIn')

Il y a deux caractères non ASCII de ce type dans le fichier :

>>> f.seek(0)
0
>>> pprint([l for l in f if any(b > 127 for b in l)])
[b'SupplementalDisclosureOfNoncashInvestingAndFinancingActivitiesAbstract\t0'
 b'001654954-17-000551\t1\t1\t\t\t\tSUPPLEMENTAL DISCLOSURE OF NON\xadCASH I'
 b'NVESTING AND FINANCING ACTIVITIES:\t\n',
 b'HotelKranichhheMember\t0001558370-17-001446\t1\t0\tmember\tD\t\tHotel Krani'
 b'chhhe [Member]\tRepresents information pertaining to Hotel Kranichh\xf6h'
 b'e.\n']

Hotel Kranichh\xf6he décodé en Latin-1 est Hôtel Kranichhöhe .

Il y a également plusieurs paires 0xC1 / 0xD1 dans le fichier :

>>> f.seek(0)
0
>>> quotes = [l for l in f if any(b in {0x1C, 0x1D} for b in l)]
>>> quotes[0].split(b'\t')[-1][50:130]
b'Temporary Payroll Tax Cut Continuation Act of 2011 (\x1cTCCA\x1d) recognized during th'
>>> quotes[1].split(b'\t')[-1][50:130]
b'ributory defined benefit pension plan (the \x1cAetna Pension Plan\x1d) to allow certai'

Je parie que ce sont vraiment U+201C GUILLEMET DOUBLE GAUCHE y U+201D GUILLEMET DOUBLE DROIT caractères ; notez le 1C y 1D des parties. On a presque l'impression que leur encodeur a pris l'UTF-16 et a supprimé tous les octets de poids fort, plutôt que d'encoder correctement en UTF-8 !

Il n'y a pas de codec fourni avec Python qui coderait '\u201C\u201D' a b'\x1C\x1D' Il est donc d'autant plus probable que la SEC a bâclé son processus d'encodage quelque part. En fait, il y a également des caractères 0x13 et 0x14 qui sont probablement sur y em les tirets ( U+2013 y U+2014 ), ainsi que 0x19 octets qui sont presque certainement des guillemets simples ( U+2019 ). Tout ce qui manque pour compléter l'image est un octet 0x18 pour représenter U+2018 .

Si nous supposons que l'encodage est cassé, nous pouvons tenter de le réparer. Le code suivant lirait le fichier et corrigerait le problème des guillemets, en supposant que le reste des données n'utilise pas de caractères autres que Latin-1, à part les guillemets :

_map = {
    # dashes
    0x13: '\u2013', 0x14: '\u2014',
    # single quotes
    0x18: '\u2018', 0x19: '\u2019',
    # double quotes
    0x1c: '\u201c', 0x1d: '\u201d',
}
def repair(line, _map=_map):
    """Repair mis-encoded SEC data. Assumes line was decoded as Latin-1"""
    return line.translate(_map)

puis applique cela aux lignes que tu lis :

with open(filename, 'r', encoding='latin-1') as f:
    repaired = map(repair, f)
    fields = next(repaired).strip().split('\t')
    for line in repaired:
        yield process_tag_record(fields, line)

Par ailleurs, en ce qui concerne le code que vous avez affiché, vous faites travailler Python plus que nécessaire. N'utilisez pas codecs.open() Il s'agit d'un code hérité qui présente des problèmes connus et qui est plus lent que la nouvelle couche E/S de Python 3. Utilisez simplement open() . Ne pas utiliser f.readlines() Vous n'avez pas besoin de lire le fichier entier dans une liste ici. Il suffit d'itérer sur le fichier directement :

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with open(filename, 'r', encoding='utf-8', errors='strict') as f:
        fields = next(f).strip().split('\t')
        for line in f:
            yield process_tag_record(fields, line)

Si process_tag_record se divise également sur les onglets, utilisez un csv.reader() et éviter de diviser chaque ligne manuellement :

import csv

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with open(filename, 'r', encoding='utf-8', errors='strict') as f:
        reader = csv.reader(f, delimiter='\t')
        fields = next(reader)
        for row in reader:
            yield process_tag_record(fields, row)

Si process_tag_record combine le fields avec les valeurs de la liste row pour former un dictionnaire, il suffit d'utiliser csv.DictReader() à la place :

def tags(filename):
    """Yield Tag instances from tag.txt."""
    with open(filename, 'r', encoding='utf-8', errors='strict') as f:
        reader = csv.DictReader(f, delimiter='\t')
        # first row is used as keys for the dictionary, no need to read fields manually.
        yield from reader

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X