Shanraq.org Shanraq.org
Ошибки и исключения: нет файла или мусор в файле
IT

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 получил строку, которую не смог превратить в число, и прямо назвал её.

Строки выше — дорога, по которой программа туда пришла: самый нижний кадр — место, где сломалось, самый верхний — с чего всё началось. Стрелки ~~~^^^ под строкой показывают, какое именно выражение виновато, если их в строке несколько.

Отсюда привычка, экономящая часы: не пугайтесь длины. Смотрите последнюю строку, потом ближайший кадр со своим файлом — ошибка почти всегда там, а не в глубине чужой библиотеки.

Карта урока

Карта урока: имя ошибки, ветка и traceback

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему total += value стоит в else, а не внутри try?
  2. Что будет, если поставить except OSError выше except FileNotFoundError?
  3. С какой строки читают 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 вслух: что случилось, где, откуда пришли.

Куда это встанет в проекте

Сводка перестаёт падать от одной плохой строки. Разбор выгрузки идёт до конца, негодные строки называются по одной, а в отчёт попадает счёт: столько-то разобрано, столько-то пропущено — и это число само по себе показатель качества источника.

Долги. Пропущенные строки мы пока печатаем на экран. Место им — в журнале рядом с отчётом, чтобы завтра было видно, что вчера не разобралось.

Ответы

Показать ответы

На вопросы

  1. Потому что в try держат только то, что может сломаться, — тогда видно, где ждали беду. Иначе ошибка следующей строки либо свалит программу в месте, которое выглядит защищённым, либо — если except окажется пошире — будет объяснена неверной причиной и тихо уйдёт в пропуски.
  2. Ветка FileNotFoundError не выполнится никогда: OSError — общий случай, и Python возьмёт первую подходящую ветку сверху. Частное всегда ставят выше общего.
  3. С последней: там тип ошибки и сообщение — что именно случилось. Выше — путь вызовов, и в нём ищут ближайший кадр со своим файлом.

К разминке

  1. Сначала 8.0, потом сообщение: float("8,0".replace(",", ".")) — это 8.0, а «нет данных» числом не становится, и ValueError уходит в except.
8.0
не число: нет данных
  1. KeyError — ошибка отсутствующего ключа. ValueError здесь не сработает: значение не портится, его просто нет.
prices = {"хлеб": 260}
try:
    print(prices["молоко"])
except KeyError:
    print("такого товара нет")
такого товара нет
  1. Голый 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

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.