Проблема
За время работы над #38 (PR #39) одна и та же правка прошла четыре итерации ревью, и три из них нашли новый дефект в том же куске кода:
except DownloadTimeoutError глотал и «скачалось больше одного CSV», и настоящие сбои ретрая → введён отдельный DownloadNoNewPathError;
- при перезаписи файла на месте старый распарсенный
dataset соединялся с новым raw-файлом → parquet и manifest расходились с CSV → добавлено сохранение копии до ретрая;
- область очистки временной копии начиналась после её создания → при падении
copy2 копия оставалась в output_root → try расширен на создание файла.
Каждый раз код был локально верным, а дефект был на границе области действия: проверка или защита стояла рядом с настоящим местом риска, а не на нём. Это не серия случайных промахов, а свойство формы кода.
Измерения
src/wordstat/collector.py — 1420 строк, 31 функция.
| Функция |
Строк |
try |
Ветвлений |
Вложенность |
_collect_one |
324 |
4 |
26 |
8 |
collect_many |
157 |
4 |
7 |
7 |
_assert_contiguous_dynamics_rows |
102 |
1 |
10 |
5 |
_download_current_view |
99 |
0 |
4 |
4 |
_select_view |
47 |
1 |
4 |
7 |
Вложенные try в _collect_one (строки 403–726):
for view in enumerate(pending_views) :564-706
try (AuthenticationRequiredError, Exception) :565-706 142 строки
try (Exception) :583-669 87 строк
try ... finally :600-633 34 строки
try (DownloadNoNewPathError) :609-630 22 строки
Четыре уровня обработки ошибок в одной функции. Ни одна проверка на такой глубине не читается целиком — именно поэтому три ревью подряд находили новое.
Цель
Разбить крупные функции collector.py на атомарные единицы с явными результатами и одним владельцем жизненного цикла у каждого ресурса. Поведение сохранить полностью: 180 тестов должны остаться зелёными без правок, кроме случаев, где тест обращается к приватному имени, которое переехало.
Подзадачи
1. Ретрай пустого экспорта (первоочередное, блокирует PR #39)
Вынести в отдельный helper, возвращающий явный результат: success(source, dataset) / no_new_path / проброс прочих ошибок. Жизненный цикл временной копии — один внешний try/finally, охватывающий создание, копирование, использование и удаление. Ни одной ветки, где source и dataset происходят из разных файлов.
2. _collect_one (324 → цель ≤ 80 строк)
Выделить: подготовку вида, скачивание с ретраем (п. 1), запись датасета и финализацию raw, обработку сбоя одного вида. Каждый вложенный try должен стать try внутри своей маленькой функции.
3. collect_many (157 строк, 4 try)
Отделить жизненный цикл общего временного каталога загрузок от обхода фраз и от обработки ошибок отдельной фразы.
4. _assert_contiguous_dynamics_rows (102 строки, 10 ветвлений)
Разделить на проверку вхождения в окно и проверку непрерывности; они сейчас переплетены.
5. _download_current_view (99 строк)
Отделить снапшот-диффинг от поллинга и от проверки «файл не убежал за пределы каталога».
Правила
- Чистый рефакторинг: никаких изменений поведения, никаких новых возможностей.
- После каждой подзадачи — все тесты зелёные,
ruff check . чист.
- Каждая подзадача — отдельный PR, чтобы ревью было обозримым.
- Все инварианты из комментариев (порядок «скачать → распарсить → записать parquet → убрать raw», зона безопасности
finalize_raw, fail-closed для dynamics) сохранить и не растерять по дороге — они документируют реальные баги.
- Мутационная проверка на каждый PR, включая границы областей
try/finally, а не только ветвления.
Порядок
Подзадача 1 делается первой и вливается в PR #39 — он сейчас на ней и заблокирован. Остальные — после.
Проблема
За время работы над #38 (PR #39) одна и та же правка прошла четыре итерации ревью, и три из них нашли новый дефект в том же куске кода:
except DownloadTimeoutErrorглотал и «скачалось больше одного CSV», и настоящие сбои ретрая → введён отдельныйDownloadNoNewPathError;datasetсоединялся с новым raw-файлом → parquet и manifest расходились с CSV → добавлено сохранение копии до ретрая;copy2копия оставалась вoutput_root→tryрасширен на создание файла.Каждый раз код был локально верным, а дефект был на границе области действия: проверка или защита стояла рядом с настоящим местом риска, а не на нём. Это не серия случайных промахов, а свойство формы кода.
Измерения
src/wordstat/collector.py— 1420 строк, 31 функция.try_collect_onecollect_many_assert_contiguous_dynamics_rows_download_current_view_select_viewВложенные
tryв_collect_one(строки 403–726):Четыре уровня обработки ошибок в одной функции. Ни одна проверка на такой глубине не читается целиком — именно поэтому три ревью подряд находили новое.
Цель
Разбить крупные функции
collector.pyна атомарные единицы с явными результатами и одним владельцем жизненного цикла у каждого ресурса. Поведение сохранить полностью: 180 тестов должны остаться зелёными без правок, кроме случаев, где тест обращается к приватному имени, которое переехало.Подзадачи
1. Ретрай пустого экспорта (первоочередное, блокирует PR #39)
Вынести в отдельный helper, возвращающий явный результат:
success(source, dataset)/no_new_path/ проброс прочих ошибок. Жизненный цикл временной копии — один внешнийtry/finally, охватывающий создание, копирование, использование и удаление. Ни одной ветки, гдеsourceиdatasetпроисходят из разных файлов.2.
_collect_one(324 → цель ≤ 80 строк)Выделить: подготовку вида, скачивание с ретраем (п. 1), запись датасета и финализацию raw, обработку сбоя одного вида. Каждый вложенный
tryдолжен статьtryвнутри своей маленькой функции.3.
collect_many(157 строк, 4try)Отделить жизненный цикл общего временного каталога загрузок от обхода фраз и от обработки ошибок отдельной фразы.
4.
_assert_contiguous_dynamics_rows(102 строки, 10 ветвлений)Разделить на проверку вхождения в окно и проверку непрерывности; они сейчас переплетены.
5.
_download_current_view(99 строк)Отделить снапшот-диффинг от поллинга и от проверки «файл не убежал за пределы каталога».
Правила
ruff check .чист.finalize_raw, fail-closed дляdynamics) сохранить и не растерять по дороге — они документируют реальные баги.try/finally, а не только ветвления.Порядок
Подзадача 1 делается первой и вливается в PR #39 — он сейчас на ней и заблокирован. Остальные — после.