Python: от данных до своей сводки Урок 10 из 56
Ошибки и исключения: нет файла или мусор в файле
Десятый урок курса по Python. Из пяти строк чужой выгрузки разобрались три: запятая — это точка, а «нет данных» и пустая строка — не числа. `try/except/else/finally`, своё имя ошибки в одну строку и главное умение — читать traceback снизу вверх, начиная с последней строки.
Зачем это нужно
В прошлом уроке assert проверял наши собственные предположения о расчёте. Сегодня начинается другое: данные, которые прислал кто-то другой.
Там, где кончается ваш код и начинается чужой файл, ошибка перестаёт быть признаком плохого программиста и становится обычным событием. Файла нет. В файле вместо числа слово. Диск полон. Сеть отвалилась на середине.
Программа, которая падает на каждой такой строке, бесполезна. Программа, которая молча пропускает всё подряд, опасна. Разница между ними — в умении назвать ошибку и решить, что с ней делать.
Сразу целиком
Файл qate.py. Запуск: python qate.py из окружения.
Обязательное — первый блок: try, except с именем ошибки, else и накопитель. Второй блок про файл и finally разобран ниже; файлами по-настоящему займёмся в следующем уроке.
"""Урок 10: ошибка — не конец программы, а решение, которое надо принять.
Данные из чужого файла приходят как есть: с запятой вместо точки, с пустой
строкой, со словом вместо числа. Программа должна знать, что делать с каждым.
"""
class BadRow(ValueError):
"""Строка, которую не удалось разобрать. Своё имя для своей ошибки.
Это класс, и сегодня он нужен только как имя: что стоит за словом class,
разобрано в двадцать первом уроке.
"""
# Так выглядит выгрузка из чужой таблицы: пять строк, три из них годные.
rows = ["8.0", "15,0", "нет данных", "", "11.4"]
def to_number(text):
"""Текст в число: запятая считается точкой, остальное — BadRow."""
clean = text.strip().replace(",", ".")
try:
return float(clean)
except ValueError:
raise BadRow(f"не число: {text!r}")
print("== разбираем строки")
total = 0.0
count = 0
for text in rows:
try:
value = to_number(text)
except BadRow as error:
print(f"пропущено — {error}")
else:
total += value
count += 1
print(f"взято: {value}")
print(f"разобрано {count} из {len(rows)}, сумма {total:.1f}")
print()
print("== нет файла и нечитаемый файл — разные ошибки")
try:
with open("dannye.csv", encoding="utf-8") as source:
print(source.readline())
except FileNotFoundError:
print("файла нет: возьмём данные из сети")
except OSError as error:
print("файл есть, но не читается:", error)
finally:
print("finally выполняется в любом случае")
Выводит:
== разбираем строки
взято: 8.0
взято: 15.0
пропущено — не число: 'нет данных'
пропущено — не число: ''
взято: 11.4
разобрано 3 из 5, сумма 34.4
== нет файла и нечитаемый файл — разные ошибки
файла нет: возьмём данные из сети
finally выполняется в любом случае
Разбор
У ошибки есть имя, и имена выстроены в семью
ValueError, FileNotFoundError, KeyError, ZeroDivisionError — это типы, как int и str. И они не разбросаны, а стоят деревом: FileNotFoundError — частный случай OSError, а OSError — частный случай Exception.
Отсюда полезное следствие: перехватывая OSError, вы ловите и «нет файла», и «нет прав», и «диск отвалился». А перехватывая Exception — вообще всё, включая свою же опечатку в имени переменной.
Образ. Врач в приёмном покое. «Плохо с животом» и «сломана рука» лечат разные люди, и первое, что делают, — называют, что случилось. Диагноз «плохо» не лечится.
except без имени — почти всегда ошибка
try:
...
except: ← так не пишут
pass
Голый except ловит всё: и ошибку в данных, и NameError от опечатки, и нажатие Ctrl-C, которым вы пытаетесь остановить программу. Вместе с ошибкой он проглатывает и причину — а pass внутри означает «мне всё равно, что случилось».
Правило простое: перехватывайте то, что умеете обработать. Остальное пусть падает — падение с понятным сообщением честнее тихой чепухи в отчёте.
else у try: под защитой только то, что может сломаться
try:
value = to_number(text)
except BadRow as error:
print(f"пропущено — {error}")
else:
total += value
count += 1
В try стоит одна строка — та, что может не получиться. Всё, что делается после успеха, ушло в else.
Так сделано не для красоты. В try держат ровно ту строку, которая может не получиться: тогда по коду видно, где ждали беду. Положи мы total += value внутрь — и защищённым стало бы выглядеть место, которое никто не защищал: у сложения свой тип ошибки, TypeError, и except BadRow его не поймает.
Хуже, когда ловят пошире. Напишите except ValueError — соблазн есть, BadRow от него и произошёл — и поставьте в try вторую рискованную строку, скажем year = int(year_text). Плохой год придёт тем же ValueError, будет объявлен «не числом» и тихо уйдёт в пропуски, а искать вы его будете в данных, а не в коде. else держит границу: в try — рискованное, в else — то, что делается, когда риск не сработал.
Порядок except имеет значение
except FileNotFoundError:
...
except OSError as error:
...
Python идёт по веткам сверху вниз и берёт первую подходящую. FileNotFoundError — частный случай OSError, поэтому частное стоит выше общего. Поменяйте местами — и первая ветка заберёт всё, а вторая не выполнится никогда.
finally выполняется всегда
finally срабатывает и после успеха, и после ошибки, и даже когда из try уже вышли по return. Там закрывают то, что нужно закрыть в любом случае.
В нашем примере файл закрывать не нужно — это делает with: он сам закроет файл, что бы ни случилось внутри. Именно поэтому файлы почти всегда открывают через with, а finally остаётся для того, что with не умеет.
raise и своё имя ошибки
class BadRow(ValueError):
"""Строка, которую не удалось разобрать."""
Одна строка — и у нас своё имя ошибки. Наследование от ValueError означает «это частный случай неверного значения»: код, который ловит ValueError, поймает и нашу.
Зачем оно, если есть готовый ValueError? Затем, что в разборе выгрузки ValueError может прилететь откуда угодно — из float, из int, из чужой библиотеки. BadRow говорит: это именно наша строка не разобралась, и ловить его можно точно.
raise поднимает ошибку. Обратите внимание, где он стоит: внутри except. Python это запоминает и при падении покажет обе:
ValueError: could not convert string to float: 'нет данных'
During handling of the above exception, another exception occurred:
...
BadRow: не число: 'нет данных'
Это удобно: видно и что случилось, и как мы это назвали. Если вторая половина мешает, пишут raise BadRow(...) from None.
Как читать traceback
Настоящая ошибка выглядит так (пути к файлам сокращены):
Traceback (most recent call last):
File "tb.py", line 12, in <module>
print(average(["8.0", "нет данных"]))
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "tb.py", line 8, in average
total += to_number(text)
~~~~~~~~~^^^^^^
File "tb.py", line 2, in to_number
return float(text)
ValueError: could not convert string to float: 'нет данных'
Читают его снизу вверх, и это главное умение сегодняшнего урока.
Последняя строка — что случилось: тип ошибки и сообщение. Здесь float получил строку, которую не смог превратить в число, и прямо назвал её.
Строки выше — дорога, по которой программа туда пришла: самый нижний кадр — место, где сломалось, самый верхний — с чего всё началось. Стрелки ~~~^^^ под строкой показывают, какое именно выражение виновато, если их в строке несколько.
Отсюда привычка, экономящая часы: не пугайтесь длины. Смотрите последнюю строку, потом ближайший кадр со своим файлом — ошибка почти всегда там, а не в глубине чужой библиотеки.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему
total += valueстоит вelse, а не внутриtry? - Что будет, если поставить
except OSErrorвышеexcept FileNotFoundError? - С какой строки читают traceback и что она говорит?
Разминка
Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.
1. Предскажите. Что напечатает эта программа?
def to_number(text):
return float(text.replace(",", "."))
for text in ["8,0", "нет данных"]:
try:
print(to_number(text))
except ValueError:
print("не число:", text)
2. Заполните пропуск. Вместо ... поставьте имя ошибки, которую здесь надо перехватить.
prices = {"хлеб": 260}
try:
print(prices["молоко"])
except ...:
print("такого товара нет")
3. Почините. Программа печатает 8.0, хотя чисел два. Найдите, куда делось второе, и сделайте так, чтобы про пропущенную строку было сказано вслух.
values = ["8.0", "15,0"]
total = 0.0
for text in values:
try:
total += float(text)
except:
pass
print(total)
Задание
Обязательное. Дано:
rows = ["520", "546,5", "", "нет цены", "498.0"]
Заведите свою ошибку BadRow и функцию to_number(text): запятая считается точкой, всё остальное поднимает BadRow с текстом, где видно саму строку. Пройдите список: годную строку печатайте как взятую, негодную — как пропущенную вместе с причиной. В конце напечатайте, сколько разобрано из скольких, и среднее с двумя знаками.
Ожидаемый вывод:
взято: 520.0
взято: 546.5
пропущено — не число: ''
пропущено — не число: 'нет цены'
взято: 498.0
разобрано 3 из 5, среднее 521.50
Готово, когда: вывод совпадает построчно; в try стоит одна строка, а накопление — в else; голого except в программе нет.
На своих данных. Возьмите список строк, как приходит из выгрузки: число, число с запятой, пустая строка, слово. Напишите to_number(text), который поднимает своё исключение на неразбираемой строке, и цикл, который годные складывает, а негодные печатает с причиной. В конце — сколько разобрано из скольких.
По желанию.
- Добавьте ветку
except ZeroDivisionErrorв расчёт среднего и проверьте её на пустом списке. - Сделайте
raise BadRow(...) from Noneи сравните traceback с прежним. - Откройте несуществующий файл без
tryи прочитайте traceback вслух: что случилось, где, откуда пришли.
Куда это встанет в проекте
Сводка перестаёт падать от одной плохой строки. Разбор выгрузки идёт до конца, негодные строки называются по одной, а в отчёт попадает счёт: столько-то разобрано, столько-то пропущено — и это число само по себе показатель качества источника.
Долги. Пропущенные строки мы пока печатаем на экран. Место им — в журнале рядом с отчётом, чтобы завтра было видно, что вчера не разобралось.
Ответы
Показать ответы
На вопросы
- Потому что в
tryдержат только то, что может сломаться, — тогда видно, где ждали беду. Иначе ошибка следующей строки либо свалит программу в месте, которое выглядит защищённым, либо — еслиexceptокажется пошире — будет объяснена неверной причиной и тихо уйдёт в пропуски. - Ветка
FileNotFoundErrorне выполнится никогда:OSError— общий случай, и Python возьмёт первую подходящую ветку сверху. Частное всегда ставят выше общего. - С последней: там тип ошибки и сообщение — что именно случилось. Выше — путь вызовов, и в нём ищут ближайший кадр со своим файлом.
К разминке
- Сначала
8.0, потом сообщение:float("8,0".replace(",", "."))— это8.0, а «нет данных» числом не становится, иValueErrorуходит вexcept.
8.0
не число: нет данных
KeyError— ошибка отсутствующего ключа.ValueErrorздесь не сработает: значение не портится, его просто нет.
prices = {"хлеб": 260}
try:
print(prices["молоко"])
except KeyError:
print("такого товара нет")
такого товара нет
- Голый
exceptсpassсъел15,0вместе с причиной:float("15,0")не разбирается, ошибка перехвачена и выброшена молча. Ошибку называют по имени и говорят о ней вслух:
values = ["8.0", "15,0"]
total = 0.0
for text in values:
try:
total += float(text)
except ValueError:
print("пропущено:", text)
print(total)
пропущено: 15,0
8.0
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.