Перейти к содержанию

412 уроков, 25 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 20 из 20 Начальный 30 мин 150 XP

Проект: надёжный разбор файла с грязными данными в Python

Итоговый проект: файл с мусором — пустые строки, слова вместо чисел, пробелы — не роняет отчёт, а попадает в журнал. Три куска: построчный разбор, счётчики с первыми ошибками и функция-парсер со сверкой.

Редакция Питоники

Финальный проект собирает раздел в одну программу. Данные — файл с показаниями датчиков, и файл этот грязный: числа с пробелами по краям, пустые строки, слова вместо значений, десятичные дроби, которые int не понимает. Требования заказчика звучат просто: отчёт должен быть посчитан по всем годным строкам, каждая битая — названа с номером, и ни одна строка не должна исчезнуть молча. Всё это уже было по кусочкам: try внутри цикла, сообщение ошибки через as e, пороги и сверка — остаётся собрать.

Кусок проектаЧто делаетКакой приём
Кусок 1Построчный разбор с печатью статуса каждой строкиtry внутри for, strip, enumerate
Кусок 2Отчёт: счётчики трёх категорий и первые 3 ошибкиСписок журнала, срез, as e
Кусок 3Функция parse_lines и сверка полнотыВозврат кортежа, assert

Данные: что в файле

В песочнице файлов нет, поэтому файл представлен списком строк — тем же самым объектом, который вернул бы readlines. Девять строк, три из них битые, две пустые. Первый шаг любого разбора — показать данные как есть, с номерами и границами строки в квадратных скобках, чтобы были видны пробелы:

шаг 0: показать данные как есть
raw_lines = [
    '  125  ',
    '',
    'семьдесят',
    '42',
    '   ',
    '3.14',
    '8',
    '1e3',
    '-7',
]

for n, line in enumerate(raw_lines, 1):
    print(f'{n}: [{line}]')
Вывод
1: [  125  ]
2: []
3: [семьдесят]
4: [42]
5: [   ]
6: [3.14]
7: [8]
8: [1e3]
9: [-7]

Диагноз по глазам: первая строка годная, но с пробелами; вторая и пятая пустые — во второй ни одного символа, в пятой одни пробелы; третья — слово, шестая — дробь, восьмая — научная запись. Четвёртая, седьмая и девятая — чистые числа. Перед разбором стоит договориться о классификации: каждая строка попадёт ровно в одну из трёх категорий — разобрано, сбой, пустая.

Контрпример: весь цикл в try

Прежде чем писать правильно, покажем неправильно. Соблазн оставить структуру урока 2 и обнять try-ом весь цикл разом:

так проект не работает
raw_lines = ['10', 'битое', '30']

values = []
try:
    for line in raw_lines:
        values.append(int(line))
    print('разобраны все:', values)
except ValueError:
    print('весь цикл в try: потеряны и битые, и хорошие')
Вывод
весь цикл в try: потеряны и битые, и хорошие

try сработал — и заодно убил результат: строка 10 была годной, но в список она не попала, потому что append выполнялся по порядку, а упало на второй строке. Один сбой отменил всю работу. Вот почему в проекте try стоит внутри цикла и обнимает одну строку данных — масштаб страховки должен совпадать с масштабом сбоя.

Кусок 1: построчный разбор

Правильный каркас: для каждой строки — свой try. Пустая строка отсеивается до рискованной операции: strip срезает пробелы, и если после него ничего не осталось — строка пропущена. Разбор — в try, сбой — в except с сообщением из as e:

кусок 1: статус каждой строки
raw_lines = [
    '  125  ',
    '',
    'семьдесят',
    '42',
    '   ',
    '3.14',
    '8',
    '1e3',
    '-7',
]

for n, line in enumerate(raw_lines, 1):
    text = line.strip()
    if not text:
        print(f'{n}: пустая строка, пропущена')
        continue
    try:
        value = int(text)
        print(f'{n}: число {value}')
    except ValueError as e:
        print(f'{n}: сбой - {e}')

print('первый проход закончен')
Вывод
1: число 125
2: пустая строка, пропущена
3: сбой - invalid literal for int() with base 10: 'семьдесят'
4: число 42
5: пустая строка, пропущена
6: сбой - invalid literal for int() with base 10: '3.14'
7: число 8
8: сбой - invalid literal for int() with base 10: '1e3'
9: число -7
первый проход закончен

Девять строк — девять вердиктов, и цикл дошёл до конца. Порядок внутри тела важен: сначала strip и проверка на пустоту, потом int. Пробелы с краёв int терпит и сам — годная строка разобралась бы и без них, — но без strip строка из одних пробелов не отличалась бы от числа, и проверка пустоты теряла бы смысл. Сообщение ValueError само называет виновника: седьмая строка отчёта показывает и слово, и дробь, и научную запись — каждый сбой с уликой.

Кусок 2: отчёт со счётчиками и первыми ошибками

Печать в моменте показывает процесс, отчёт — результат. Кусок 2 копит три категории: разобранные числа в список, сбои — в журнал с номером строки и сообщением, пустые — счётчиком. В конце печатается сводка и первые три ошибки: срез error_log[:3] берёт начало журнала, номера строк — от enumerate с единицы:

кусок 2: сводка и первые 3 ошибки
raw_lines = [
    '  125  ',
    '',
    'семьдесят',
    '42',
    '   ',
    '3.14',
    '8',
    '1e3',
    '-7',
]

parsed = []
error_log = []
empty = 0

for n, line in enumerate(raw_lines, 1):
    text = line.strip()
    if not text:
        empty += 1
        continue
    try:
        parsed.append(int(text))
    except ValueError as e:
        error_log.append((n, text, str(e)))

print('строк всего:', len(raw_lines))
print('разобрано:', len(parsed), '- сумма', sum(parsed))
print('сбоев:', len(error_log))
print('пустых пропущено:', empty)
print()
print('первые 3 ошибки:')
for n, text, message in error_log[:3]:
    print(f'  строка {n}: {text!r} -> {message}')
Вывод
строк всего: 9
разобрано: 4 - сумма 168
сбоев: 3
пустых пропущено: 2

первые 3 ошибки:
  строка 3: 'семьдесят' -> invalid literal for int() with base 10: 'семьдесят'
  строка 6: '3.14' -> invalid literal for int() with base 10: '3.14'
  строка 8: '1e3' -> invalid literal for int() with base 10: '1e3'

Четыре числа, сумма сто шестьдесят восемь, три сбоя, две пустые — отчёт отвечает на все вопросы заказчика разом. Каждая запись журнала — кортеж из трёх значений: номер, очищенный текст, сообщение ошибки; распаковка в цикле печати даёт колонки без индексов. Спецификация {text!r} печатает значение в кавычках: даже если бы строка состояла из одних пробелов, в отчёте было бы видно, что в ней не пусто. Одна битая строка — это запись в журнале, а не причина ронять весь отчёт.

Сверка: ни одна строка не потеряна

Отчёту нужна гарантия полноты: три категории обязаны сложиться в общее число строк. Это работа для assert из урока 18 — инвариант проекта, нарушение которого означает баг в разборе:

инвариант полноты
raw_lines = ['10', '', 'x', '20']

parsed = 0
errors = 0
empty = 0
for line in raw_lines:
    text = line.strip()
    if not text:
        empty += 1
    else:
        try:
            int(text)
            parsed += 1
        except ValueError:
            errors += 1

print('разобрано:', parsed, '- сбоев:', errors, '- пустых:', empty)
assert parsed + errors + empty == len(raw_lines), 'потеряны строки'
print('сверка сошлась: ни одна строка не потеряна')
Вывод
разобрано: 2 - сбоев: 1 - пустых: 1
сверка сошлась: ни одна строка не потеряна

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

Кусок 3: функция parse_lines

Разбор в проекте встретится не один раз — файлы приходят каждый день. Время завернуть логику в функцию: на входе список строк, на выходе — кортеж из трёх категорий. Код тела не изменился ни на символ, изменилась упаковка:

кусок 3: parse_lines и её отчёт
raw_lines = [
    '  125  ',
    '',
    'семьдесят',
    '42',
    '   ',
    '3.14',
    '8',
    '1e3',
    '-7',
]

def parse_lines(lines):
    numbers = []
    error_log = []
    skipped = 0
    for n, line in enumerate(lines, 1):
        text = line.strip()
        if not text:
            skipped += 1
            continue
        try:
            numbers.append(int(text))
        except ValueError as e:
            error_log.append((n, text, str(e)))
    return numbers, error_log, skipped

numbers, errors, skipped = parse_lines(raw_lines)
print('числа:', numbers)
print('сумма:', sum(numbers))
print('сбоев:', len(errors))
print('пустых:', skipped)
Вывод
числа: [125, 42, 8, -7]
сумма: 168
сбоев: 3
пустых: 2

Функция ничего не печатает — она возвращает данные, а печать остаётся вызывающему коду: так parse_lines можно вызвать в тесте, в другом отчёте или в скрипте с другим выводом. Журнал сбоев теперь часть возвращаемого кортежа, и вызывающий сам решает, сколько ошибок показать. Три ошибки этого файла — весь журнал; на файле из тысячи строк пригодился бы порог:

float: спасти ещё две строки

Три сбоя файла — не одинаковые. Слова семьдесят — настоящий мусор, а 3.14 и 1e3 — законные числа, просто не для int. Проверим, что сказал бы float из урока 12:

float на тех же строках
for text in ['3.14', '1e3', 'семьдесят']:
    try:
        print(text, '-> float разобрал:', float(text))
    except ValueError:
        print(text, '-> не число даже для float')
Вывод
3.14 -> float разобрал: 3.14
1e3 -> float разобрал: 1000.0
семьдесят -> не число даже для float

float забрал бы две строки из трёх — и это решение о требованиях, а не о коде: если датчик умеет дроби, разбор строится на float, а int остаётся только для счётчиков строк. Важно, что тип ошибки не меняется: float падает с тем же ValueError, и весь каркас проекта — try, except, журнал — пережил бы замену без единой правки.

От песочницы к настоящему файлу

Единственное, что отличает проект от настоящей программы, — две строки, где файл превращается в список строк:

как строки приходят из настоящего файла
with open('data.txt', encoding='utf-8') as f:
    raw_lines = f.readlines()
В песочнице файлов нет, поэтому в проекте список строк лежит прямо в коде. На настоящих данных readlines вернёт те же строки с переводами строк на конце — strip в разборе срежет и их, проект не изменится.

Что проект закрепил

  • масштаб try равен масштабу сбоя: одна строка данных — одна попытка, весь цикл в try теряет годные строки вместе с битыми;
  • три категории данных — разобрано, сбой, пустая — считаются раздельно и складываются в общий итог;
  • журнал сбоев хранит номер, значение и сообщение: улики собираются в момент сбоя, показываются по решению отчёта;
  • инвариант полноты с assert ловит потерю строк раньше, чем её увидит заказчик;
  • смена int на float не меняет тип ошибки — каркас try и журнала живёт при любой замене разбора.

Раздел «Исключения» окончен: от первого traceback до программы, которая переживает грязный файл и отчитывается о каждой строке. Программы, которым можно доверять данные, написаны именно на этих приёмах — и дальше они понадобятся сразу: файлы Excel в openpyxl падают на чужих книгах так же, как int на слове семьдесят, а тесты в pytest превращают assert-инварианты этого проекта в проверки целого кода.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

lines = ['7', '', 'x']
ok = 0
bad = 0
for line in lines:
    text = line.strip()
    if not text:
        continue
    try:
        int(text)
        ok += 1
    except ValueError:
        bad += 1
print(ok, bad)
errors = [(3, 'abc'), (6, '3.14')]
for n, text, msg in errors[:3]:
    print(f'строка {n}: {text!r}')
print('конец журнала')
values = ['  42  ', '', 'x']
parsed = []
for line in values:
    text = line.strip()
    if not text:
        continue
    try:
        parsed.append(int(text))
    except ValueError:
        pass
print(parsed, sum(parsed))
Проверь себя
0 / 7

1. Почему в проекте try стоит внутри цикла, а не вокруг всего цикла?

2. Зачем в разборе strip до int, если int и сам терпит пробелы?

3. Из чего состоит запись журнала сбоев в проекте?

4. Что напечатает цикл по error_log[:3], если в журнале две записи?

5. Что гарантирует assert parsed + errors + empty == len(raw_lines)?

6. Почему замена int на float в проекте почти не требует правок?

7. Почему parse_lines возвращает данные, а не печатает их?

Карточки терминов
Запомнено: 0 / 8
Практика

Дан список строк file_data: ['15', ' ', 'abc', '7', '']. Разберите его по схеме проекта: пустые строки после strip пропускайте, годные числа складывайте в numbers, сбои считайте счётчиком bad. В конце напечатайте numbers и три строки отчёта: разобрано N, сбоев N, пустых N.

practice.py
Вопросы и ответы по уроку

Как разобрать файл с битыми строками и не уронить программу?

Ставить try внутрь цикла вокруг разбора одной строки: for n, line in enumerate(lines, 1): try: numbers.append(int(line.strip())) except ValueError: журнал. Сбой превращается в запись журнала с номером строки, а остальные строки разбираются как ни в чём не бывало.

Урок 16: ошибки в циклах

Как показать первые N ошибок разбора?

Копить сбои в список кортежей (номер строки, значение, сообщение) и напечатать начало срезом: for n, text, msg in error_log[:3]: print(f'строка {n}: {text}'). Срез не падает, даже если записей меньше трёх.

Как убедиться, что при разборе не потерялись строки?

Считать три категории — разобранные, сбои, пустые — и сверять их сумму с числом строк: assert parsed + errors + empty == len(lines), 'потеряны строки'. Инвариант ловит и потерю, и двойной счёт раньше, чем отчёт попадёт к заказчику.

Урок 18: assert

Нужен ли strip, если int сам терпит пробелы?

Да: strip нужен не для int, а для классификации. Строка из одних пробелов без strip упала бы в журнал сбоев как мусор, хотя это просто пустая строка. После strip она опознаётся и пропускается отдельной категорией.

Что делать с дробями вроде 3.14 в файле чисел?

Если данные допускают дроби, разобрать их float-ом: float('3.14') и float('1e3') работают, а падает float с тем же ValueError, что и int, — каркас try и журнала менять не нужно. Слова вроде семьдесят останутся сбоями при любом разборе.

Урок 12: ValueError

Как перейти от списка строк в коде к настоящему файлу?

Заменить объявление списка на чтение: with open('data.txt', encoding='utf-8') as f: raw_lines = f.readlines(). Строки из файла несут переводы строк на конце — strip в разборе срезает их, поэтому весь остальной проект не меняется.

Урок 13: файлы и FileNotFoundError

Понравился урок? Сошлитесь на него

«Одна битая строка — это запись в журнале, а не причина ронять весь отчёт.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.