Перейти к содержанию
Публикация AiManual

Как разрезать монолит на Python-пакеты: опыт Charoite, границы модулей и сторож на AST вместо import-linter

В проекте Charoite 29 тысяч строк и около 60 файлов лежали в одной папке src/. Разбираем план из семи пакетов со стрелками зависимостей только вниз, самодельный

Коротко

Что будет в материале

  1. 01

    Почему монолит из 29 тысяч строк решили разрезать

  2. 02

    План из семи пакетов и стрелки зависимостей только вниз

  3. 03

    Почему import-linter не подошёл и как появился самодельный сторож на AST

  4. 04

    Порядок фаз: почему сначала развязки, а потом переезд

Почему монолит из 29 тысяч строк решили разрезать

Короткий ответ: пакеты понадобились, чтобы границы кода держали тесты, а не память разработчика. 19 сентября 2026 года в проекте Charoite, локальном AI-ассистенте для встреч, весь код лежал в одной папке src/: 29 тысяч строк, около 60 файлов, ни одного пакета. В pyproject.toml список модулей был пустым, всё запускалось напрямую из репозитория. Описание рефакторинга автор опубликовал на Habr.

Разрезать монолит помог план из семи пакетов (core, llm, graph, cloud, audio, meeting, app) со стрелками зависимостей только вниз, самодельный сторож на AST, сверяющий граф импортов с layout.json в CI, и изменённый порядок фаз: сначала развязки через инжекцию зависимостей, потом перенос файлов. По плану переезд занимал один день, фактически ушёл восемь.

Цели разрезания автор формулирует так: чтобы границы держали тесты, а не его память; чтобы граф встреч можно было наполнять из разных источников, включая диктовку и чужую запись; чтобы отдельный кусок системы можно было попробовать одной командой, не ставя весь Charoite целиком.

Наглядный пример, зачем это нужно. Поиск по графу встреч включает связи между узлами, русскую морфологию, досье по людям и системам. Отдать его отдельным пакетом в лоб нельзя: в придачу идут запись звука, диаризация и onnxruntime. Пока всё лежит в одной папке, эта связка незаметна, но она закрывает путь к переиспользованию. Сам граф при этом не знает слова «встреча», и это полезный ориентир: слой работает с абстракцией, а не со сценарием.

План из семи пакетов и стрелки зависимостей только вниз

Раскладка выглядит так: core для базовых вещей, llm для работы с моделями, graph для графа знаний, cloud для внешних сервисов, audio для звука и диаризации, meeting для сценария встреч, app для точек входа. Правило одно: стрелки зависимостей идут только вниз. Верхний слой может знать про нижний, обратное запрещено.

Слои в коде уже были, их ничего не держало. Границы держались в голове автора, и этого хватало, пока файлов было немного. При 60 файлах и 29 тысячах строк память перестаёт быть надёжным хранилищем архитектуры.

Формальное описание границ лежит в layout.json: у каждого модуля указан слой, у слоёв задан порядок, а ребро против стрелки разрешено только с номером карточки. Рёбер против течения сейчас три, у каждого есть карточка. Это исключения, которые видно и можно обсуждать, а не норма.

Разделение core на base и runtime

По первоначальному плану в core шли safe_write, frontmatter, конфиг и коды. На практике core оказался двумя слоями, и на него ушла неделя. Смысл разделения: базовые абстракции (base) не должны тянуть runtime-зависимости. Пакету, которому нужен только интерфейс, незачем получать вместе с ним запуск, рантайм-конфигурацию и всё, что к ним прилагается.

Для графа импортов это даёт конкретный эффект. Одна широкая стрелка «все зависят от core» распадается на две узкие. Меньше модулей попадает в транзитивные зависимости, меньше поводов для цикла, а сторож точнее показывает, кто и зачем тянет лишнее.

Вычисление корня данных в одном месте

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

С 20 сентября корень данных вычисляется в одном месте, в модуле-каноне, и сторож следит, чтобы второго такого места не появилось. С 23-го точки входа обязаны назвать корень сами. Правило простое: путь к данным приходит извне, а не выводится из местоположения файла.

Почему import-linter не подошёл и как появился самодельный сторож на AST

Нулевая фаза плана: поставить import-linter с контрактами по текущим границам. Инструмент решает ровно эту задачу, но в проекте не завёлся по прозаичной причине: import-linter требует импортируемый пакет, которого как раз и не было. Получился замкнутый круг. Пакеты нужны, чтобы линтер границ заработал, а линтер нужен, чтобы делать пакеты безопасно.

Вместо import-linter появился свой сторож. Он проходит по AST всех файлов, собирает граф импортов и сверяет его с layout.json. Проверка не импортирует модули: разбор синтаксического дерева видит импорты в коде, который в этот момент не запускается. Именно это снимает проблему с отсутствующим пакетом и делает проверку дешёвой.

Как устроен layout.json и карточки исключений

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

{
  "layers": ["app", "meeting", "audio", "cloud", "llm", "graph", "core"],
  "modules": {
    "<имя модуля>": "<слой>"
  },
  "exceptions": [
    { "from": "<модуль>", "to": "<модуль>", "card": "<номер>" }
  ]
}

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

Запуск сторожа в CI

Сторож запускается как тест в CI, то есть нарушение границ блокирует merge. 20 сентября проверка ушла в main. Карта зависимостей лежит рядом, в docs/design/, и обновляется вместе с кодом, чтобы конфигурация не расходилась с реальностью.

Порядок фаз: почему сначала развязки, а потом переезд

Изначальный план включал шесть фаз: забор (0), переезд (1), разрез graph_updater (2), инжекция (3), самостоятельные пакеты (4), демон (5). В день переезда порядок поменяли на 0 → 3 → 2 → 1 → 4, демон остался последним. Причина: двигать файлы с перепутанными связями значит переносить путаницу в новые папки, только теперь её сложнее найти.

Развязки до переезда превращают перенос в механическую операцию. Пока модули вызывают друг друга напрямую, перенос файла меняет не только путь, но и весь клубок зависимостей вокруг него. После развязки связи собираются на уровне приложения, и переезд сводится к смене путей и правке импортов.

Ещё одно правило дня переезда: фичи не мёрджили. Большой механический diff не смешивается с логическими изменениями, и причину поломки искать заметно проще.

Инжекция зависимостей как подготовка к переезду

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

На разрезе graph_updater эффект виден лучше всего. Разбить его на части до развязки трудно, потому что каждая часть тянет за собой остальные. После инжекции части соединяются на уровне вызова, и переезд в src/charoite_graph/ перестаёт быть пересадкой всего дерева зависимостей.

Переезд без шимов: как обошлись одной строкой в conftest.py

План предусматривал шимы на старых местах на один релиз, чтобы внешние скрипты не упали после смены путей. Шимы на все модули не понадобились: хватило одной строки в conftest.py. Что именно эта строка делает, автор не раскрывает, поэтому воспринимать её как универсальный рецепт не стоит.

Масштаб работ объясняет, почему шимы вообще рассматривали. В src/charoite_graph/ переехало 8 модулей, при этом изменения затронули 53 потребителя и 74 файла. При таком количестве правок страховка в виде прокси-модулей выглядит разумно, но на практике обошлись настройкой тестового окружения.

Приёмка пакета: запуск восьми файлов в отравленном окружении

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

Отравленное окружение означает, что параметры и пути заданы намеренно враждебно: пакет не должен находить данные основного проекта и не должен молча подхватывать чужие настройки. Модель эмбеддингов подменяют заглушкой, чтобы прогон не зависел от тяжёлой зависимости и давал предсказуемый результат. Пакет, который рассчитывает на реальные данные или настоящий сервис, упадёт на этой проверке, а не в проде через неделю.

Такой подход совпадает с логикой работы AI-агентов по мелким фазам с машинной проверкой: сначала автоматический критерий, потом взгляд человека. Похожий приём разобран в материале про ночной марафон локальной 27B на двух GPU, где агент работал по фазам с проверяемым результатом на каждом шаге.

Сколько это заняло и как помог DeepSeek

По плану переезд занимал один день. Фактически ушло восемь дней, переехало восемь модулей. Неделя из них пришлась на core, который оказался двумя слоями. Цифры стоит держать в голове тем, кто оценивает рефакторинг монолита по числу файлов: 74 затронутых файла и 53 потребителя дают нелинейный рост времени.

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

Что в итоге получилось и какие выводы можно перенести на свой проект

Итог по фактам: план из семи пакетов, стрелки зависимостей только вниз, сторож на AST в CI, layout.json с карточками исключений, корень данных в одном модуле-каноне, приёмка через изолированный запуск. В src/charoite_graph/ переехало 8 модулей, изменения затронули 53 потребителя и 74 файла. Рёбер против стрелки сейчас три, каждое с карточкой.

  • import-linter может не подойти на старте: без импортируемого пакета он не запустится. Разбор AST работает с исходниками напрямую и снимает это ограничение.
  • Порядок фаз важнее скорости. Развязки до переезда дешевле, чем разбор путаницы, перенесённой в новые папки.
  • Шимы нужны не всегда. В Charoite вместо них хватило одной строки в conftest.py.
  • Корень данных вычисляйте в одном месте. Привязка к __file__ тихо ломается при любом перемещении файла.
  • AI ускоряет механическую часть переезда, но не проектирование границ. Решения о слоях, инжекции и исключениях принимает человек.

Сроки закладывайте с запасом: переезд занял в восемь раз больше запланированного. Если в вашем монолите нет пакетов и нет проверки границ, начинайте с развязок и сторожа, а перенос файлов ставьте после них. Опыт описывает один проект Charoite, поэтому конкретные сроки в другой кодовой базе будут другими.

Подписаться на канал