08 апр. 2025 г.·7 мин чтения

Упаковка Python для внутренних сервисов без хаоса

Упаковка Python для внутренних сервисов становится проще, когда у вас одна понятная структура репозитория, ясные инструменты сборки и простые правила версий, которые не дают скриптам расползаться.

Упаковка Python для внутренних сервисов без хаоса

Почему внутренние Python-репозитории превращаются в беспорядок

Большая часть внутреннего Python-кода не начинается как продукт. Обычно все стартует с быстрого скрипта: выгрузить отчет, исправить проблему с данными или вызвать один API. На день-два этого хватает. Беспорядок начинается тогда, когда команда просто копирует этот скрипт в новые папки и меняет его ровно настолько, чтобы он снова заработал.

Через несколько недель один файл уже превратился в сервис, но никто так и не остановился, чтобы решить, что это вообще такое. Это пакет, который должен импортировать другой код? Это запускаемое приложение? Или просто ops-скрипт? Когда ответ размытый, люди смешивают все подряд и надеются, что Python сам все разрулит.

Потом импорты начинают ломаться странным образом. Один человек запускает код из корня репозитория, и все работает. Другой запускает тот же файл из его собственной папки и получает ошибку относительного импорта. Третий добавляет хаки с sys.path, чтобы проблема исчезла хотя бы сейчас. Обычно такое исправление только усложняет следующую проблему.

Типичный пример выглядит так: у команды есть scripts/cleanup.py, потом его копируют в service_a/, затем добавляют общие хелперы в utils.py, а потом другой сервис импортирует эти хелперы напрямую. В итоге один одноразовый скрипт и production-код зависят от одних и тех же рыхлых файлов, без четкой границы. Маленькое изменение в одном месте ломает что-то далеко отсюда.

Вот почему упаковка Python для внутренних сервисов так часто идет не так. Кодовая база растет по принципу копирования, а не по плану. Команды держат запускаемый код, переиспользуемый код и одноразовые эксперименты в одном дереве. Как только это происходит, каждая новая папка кажется безобидной, но на деле добавляет еще одно место, где импорты, тесты и деплой могут разъехаться.

Решите, где сервис, где пакет, а где скрипт

Большинство проблем с репозиториями начинаются еще до появления инструментов. Команды называют все подряд «приложением», а потом импорты расползаются между папками, и временный файл медленно превращается в production-код.

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

Такое разделение должно быть видно в репозитории, а не только в чате команды.

  • services/ для разворачиваемых единиц
  • packages/ для переиспользуемого кода
  • scripts/ для ручных или временных задач
  • tests/ рядом с кодом, который они покрывают, или внутри каждой единицы
  • один README на каждую единицу с владельцем и назначением

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

Небольшой пример помогает понять это лучше. Допустим, у одной команды есть API, которое принимает заказы, ночная задача синхронизации и инструмент для исправления данных, который инженер запускает раз в месяц. API и ночная синхронизация — это сервисы. Код валидации заказов должен жить в пакете, если он нужен обоим. Инструмент исправления данных остается скриптом, пока у него не появятся регулярный график, алерты и операционная ответственность.

У каждого пакета также должен быть названный владелец. Не комитет. Один человек или одна небольшая команда. Запишите, для чего нужен пакет, чего в нем быть не должно, и кто утверждает изменения в его import surface. Уже этого достаточно, чтобы избежать большой части хаоса.

Выберите одну структуру репозитория и не усложняйте

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

Используйте src-структуру. Тогда импорты будут вести себя так же, как после установки, а не так, как они случайно работают, потому что кто-то запустил код из корня репозитория. Это быстро ловит плохие импорты, а в этом и есть весь смысл.

repo/
  pyproject.toml
  src/
    invoice_worker/
      __init__.py
      main.py
    customer_sync/
      __init__.py
      main.py
    common/
      __init__.py
      auth.py
      db.py
      settings.py
  tests/
    invoice_worker/
      test_main.py
    customer_sync/
      test_main.py
    common/
      test_auth.py

Каждый разворачиваемый сервис получает свою папку и свою точку входа, обычно main.py или cli.py. Переиспользуемый код не должен жить внутри папки сервиса только потому, что одна команда написала его первой. Перенесите общий код в один общий пакет и держите его там.

Правило звучит жестко, но оно не дает маленьким репозиториям превратиться в кучу полусвязанных модулей. Если invoice_worker импортирует что-то из customer_sync, это уже тревожный сигнал. Оба должны импортировать из common, если код действительно общий.

Тесты должны повторять структуру пакета. Когда разработчик открывает tests/customer_sync, он должен видеть тесты для src/customer_sync. Без догадок, без поиска по случайным папкам.

Названия важнее, чем кажется. Называйте папки по бизнес-задачам, например invoice_worker, customer_sync или fraud_checks. Не называйте их в честь машин, контейнеров или старых целей деплоя. server1_api быстро становится нелепым, а через полгода никто уже не понимает, что это такое.

Oleg часто подталкивает команды к такой простой структуре для внутренних сервисов по одной причине: она остается читаемой, когда команда растет. Вы можете передать репозиторий новому инженеру, и он сразу поймет, что запускается, что импортируется и что ни в коем случае не должно выходить в виде приложения.

Выбирайте инструменты с минимумом лишних деталей

Большинство команд попадают в неприятности не потому, что упаковка Python сама по себе сложна. Проблемы начинаются потому, что у каждого репозитория вырабатываются свои привычки. Один сервис использует setuptools, другой — Poetry, третий — самописный shell-скрипт, и никто уже не понимает, какая команда является настоящей.

Начните с pyproject.toml и считайте его единственным местом для метаданных пакета, зависимостей и настроек сборки. Когда люди могут открыть один файл и увидеть, как устроен проект, они делают меньше предположений и меньше локальных правок.

Один файл — один инструмент

Выберите один инструмент упаковки для всего репозитория и придерживайтесь его. Для нового репозитория Hatch или PDM помогают держать все чисто и понятно. Если команда уже умеет работать с setuptools и текущая схема работает, оставьте ее. Стабильность важнее моды.

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

Простое правило помогает: один репозиторий, один инструмент сборки, одна команда установки. Если потом понадобится поменять инструмент, делайте это как запланированную уборку, а не по кускам.

Держите рабочий процесс одинаковым в dev и CI

Используйте один и тот же процесс с виртуальным окружением везде. Если разработчики создают окружение одним способом, а CI собирает проект другим, ошибки просачиваются очень быстро. Пакет устанавливается локально, а задача деплоя падает, потому что зависимости были разрешены иначе.

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

Разделите запускаемый код и переиспользуемый код

Упаковать общий код
Превратите скопированные хелперы во внутренние пакеты с владельцами, тестами и версиями.

Многие проблемы с импортами начинаются тогда, когда полезный скрипт разрастается в полу-приложение. Кто-то добавляет логику запуска, загрузку конфигурации и несколько хелперов в один файл. Через неделю другой сервис импортирует этот файл, потому что он «уже работает».

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

Хорошо работает простое разделение:

src/
  billing_service/
    app.py
    api.py
    domain/
      invoices.py
      pricing.py
scripts/
  backfill_invoices.py

app.py запускает программу. Он читает настройки, настраивает логирование, собирает зависимости и запускает CLI, worker или web server. В нем не должно быть правил расчета биллинга. Эти правила нужно вынести в импортируемые модули, например в domain/invoices.py.

Так становится понятнее направление. Приложения вызывают код пакета. Скрипты вызывают код пакета. Скрипты никогда не должны становиться поставщиками кода для сервисов.

Если задаче backfill нужен код для работы со счетами, она должна импортировать публичную функцию из пакета, а не лезть внутрь scripts/backfill_invoices.py и не копировать код из app.py. Одно это правило убирает много скрытой связности.

Хаки с PYTHONPATH обычно просто скрывают проблему структуры. Если людям приходится подправлять shell, чтобы импорты заработали, репозиторий сам подсказывает, что граница пакета выбрана неправильно. Устанавливайте пакет нормально в development и запускайте код из пакета, а не из случайных папок.

Открывайте наружу несколько публичных функций, которым может доверять другой код. Внутренние хелперы лучше оставить приватными. Например, create_invoice() — это более удачная точка импорта, чем _build_invoice_rows(), спрятанная на три файла глубже.

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

Задайте правила версий, которым люди смогут следовать

Версионирование работает только тогда, когда человек может выбрать номер меньше чем за минуту. Если каждый раз нужно спорить, никто не будет пользоваться правилами как следует. Для упаковки Python для внутренних сервисов держите правила простыми и строгими.

Используйте patch-версию для исправлений, которые не меняют то, как другой код вызывает пакет. Если вы исправили баг в парсере, улучшили логирование или ускорили функцию, это patch-изменение. Другие сервисы должны обновляться без изменений в импортах и точках вызова.

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

Используйте major-версию, когда другому коду нужно меняться. Если вы переименовали модуль, перенесли импорты, удалили функцию или изменили параметры так, что это ломает вызывающий код, повышайте major. Команды обычно слишком редко используют major-версии, и именно здесь начинается импортный хаос.

Это правило важно даже внутри одного репозитория. Общая внутренняя библиотека все равно остается пакетом с пользователями. Если billing_core меняется с from billing_core.client import Client на from billing_core.api import Client, дайте ей новый major-релиз, даже если код лежит рядом с сервисом, который его использует.

Запишите правило один раз, в короткой заметке для команды, и держите его на виду в репозитории. Это может быть так просто: patch для исправлений, minor для безопасных дополнений, major для breaking changes, а у каждой внутренней библиотеки — своя версия. Если человек не может понять, какой bump нужен, пусть спросит до merge, а не после релиза.

Звучит скучно. И это хорошо. Скучное версионирование экономит много времени.

Настройте новый внутренний сервис по шагам

Начинайте до того, как напишете код. Выберите одно понятное имя пакета, напишите одно предложение о его задаче и назовите команду или человека, который за него отвечает. Если у сервиса нет владельца, маленькие проблемы очень быстро превращаются в постоянный беспорядок.

Для упаковки Python для внутренних сервисов простая стартовая форма почти всегда лучше, чем умная.

  1. Сначала создайте pyproject.toml. Добавьте имя проекта, версию Python, зависимости и build backend, который ваша команда уже использует.
  2. Поместите импортируемый код в src/your_package/. Оставьте небольшой __init__.py и добавьте main.py только если сервису нужна командная точка входа.
  3. Сразу добавьте tests/. Даже две или три базовые проверки помогают поймать плохие импорты и сломанную упаковку до того, как сервис вырастет.
  4. Если сервису нужен общий код, подключите этот общий пакет как зависимость. Не копируйте папку common в репозиторий. Копии быстро расходятся, и через месяц никто не знает, какая из них правильная.
  5. Настройте CI до первого merge. Запускайте lint-проверки, тесты и одну чистую установку, которая доказывает, что импорты работают из собранного пакета, а не только из корня репозитория.

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

Пометьте первый релиз до того, как его начнет использовать другая команда. 0.1.0 достаточно. Этот тег дает людям что-то стабильное, на что можно опираться, и показывает всем, где начинается breaking change. Если другая команда зависит от неотмеченной ветки, вы уже снова в импортном хаосе.

Простой пример для команды

Навести порядок в импортах
Попросите Oleg проверить структуру репозитория и границы пакетов.

У одной команды есть две Python-программы. Billing worker запускается по расписанию и применяет правила к счетам перед списанием денег с клиентов. Команда отчетов раз в месяц строит финансовые отчеты на тех же данных по счетам.

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

Чистое решение совсем небольшое. Поместите логику счетов в один общий пакет, например invoice_rules. В этом пакете должен жить обычный Python-код: расчеты, валидация, правила дат и тесты. Он не должен сам запускать worker, читать флаги CLI или подключаться к очередям.

Затем держите запускаемые части отдельно. Billing worker остается своим приложением со своим планировщиком, логикой повторных попыток и логированием. Генератор отчетов тоже остается отдельным приложением, даже если это всего лишь команда, которую кто-то запускает из CI или с ноутбука. Это все равно приложение, потому что люди запускают его напрямую.

Такой репозиторий легко читать:

  • packages/invoice_rules для общей бизнес-логики
  • services/billing_worker для запланированного worker
  • apps/report_generator для команды, которая создает отчеты

Теперь команда находит баг в том, как credit notes уменьшают налог. Они исправляют его один раз в invoice_rules, выпускают версию 0.4.2 и публикуют этот пакет внутри компании. После этого worker обновляет свою зависимость до 0.4.2. Report generator делает то же самое, когда команда готова.

Никто не копирует файлы между папками. Никто не импортирует код из соседнего приложения с помощью path hacks. Одно исправление доходит до обоих инструментов через обычное обновление пакета, а в этом и есть смысл упаковки Python для внутренних сервисов.

Ошибки, которые создают импортный хаос

Импортный хаос обычно начинается с удобства. Кто-то запускает модуль из любой папки, где он сейчас находится, добавляет быстрый sys.path-тюнинг и идет дальше. На одном ноутбуке это работает, а потом ломается в CI, потому что импорты зависят от текущей рабочей директории, а не от структуры пакета.

Та же самая короткая дорога появляется тогда, когда один сервис импортирует код из соседнего сервиса. Биллинг-приложение лезет в репозиторий notifications за одним хелпером, потом другой команде нравится этот подход, и она повторяет его. Вскоре ни один сервис уже не может стоять сам по себе. Если код действительно общий, вынесите его в небольшой внутренний пакет с собственным именем и версией. Если он нужен только одному сервису, оставьте его там.

Привычка делать utils.py — еще один тихий источник беспорядка. Такой файл появляется почти в каждом репозитории. Потом он распадается на utils.py, helpers.py и common.py, и никто уже не понимает, что именно там лежит. Пакет с понятным названием, например date_parsing или logging_helpers, проще импортировать, тестировать и заменять.

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

Частая ловушка в упаковке Python для внутренних сервисов — считать скрипт разворачиваемым приложением только потому, что он уже работает в production. Скрипт, который отправляет один отчет, может оставаться скриптом. Но как только у него появляются конфиги, повторные попытки, обработка окружения, логирование и задача деплоя, это уже не просто скрипт. Дайте ему нормальную структуру пакета, пока сверху не навалилось еще больше кода.

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

Быстрая проверка перед merge и релизом

Привести в порядок один репозиторий
Начните с одного Python-сервиса и получите практичную структуру, которую команда сможет сохранить.

Перед релизом merge должен ответить на несколько простых вопросов. Если команда не может пройти их за десять минут, в репозитории все еще есть скрытая связность. Именно так упаковка Python для внутренних сервисов превращается в ночные фиксы импортов.

  • Запустите проект на чистом ноутбуке или в чистом контейнере. После одного шага установки тесты должны запускаться. Если кому-то нужно помнить дополнительные команды или копировать локальные файлы, репозиторий еще не готов.
  • Запускайте каждый сервис через свою точку входа. API, worker или задача по расписанию должны иметь понятный способ запуска без логики вида открой этот файл из корня репозитория.
  • Запускайте код, не трогая PYTHONPATH. Если импорты падают, пока кто-то не поправит настройки shell, значит структура пакета маскирует проблему в организации кода.
  • Явно указывайте каждую зависимость и ее версию там, где этого ожидает команда. Не полагайтесь на ноутбук разработчика, где нужный пакет уже случайно установлен.
  • Подбирайте bump версии под изменение. Исправления багов получают patch-версию, новое поведение — minor-версию, а breaking changes — major-версию.

Эти проверки кажутся мелкими, но они ловят именно тот беспорядок, который обычно проскакивает. Репозиторий может выглядеть аккуратным и при этом зависеть от локального состояния, магии редактора или shell-профиля одного человека.

Представьте команду с двумя внутренними сервисами и общим пакетом. Один разработчик добавляет хелпер, забывает поднять версию пакета, а тесты у него на машине все равно проходят. Другой разработчик забирает ветку на новый ноутбук, устанавливает все и получает ошибки импорта, потому что сервис работает только благодаря локальному path hack. Это не невезение. Это релизный процесс, который пропустил последние пять минут дисциплины.

Если изменение не проходит хотя бы одну из этих проверок, не merge-ите его с обещанием починить потом. Исправьте границу пакета, точку входа или версию прямо сейчас. Так быстрее, чем разбирать деплой, который сломался по причинам, которые никто не может воспроизвести.

Что делать дальше

Большинству команд не нужен большой упаковочный reset. Им нужен один записанный набор правил, один стартовый репозиторий и несколько проверок, которые останавливают плохие привычки до того, как они расползутся.

Запишите правило структуры на одной странице и держите его простым. Определите, где живет код сервиса, где живет переиспользуемый код пакета, как работают запускаемые точки входа и что никогда не разрешено. Обычно это означает никаких ad hoc правок PYTHONPATH, никаких импортов из соседних папок и никакого общего кода, спрятанного внутри репозитория сервиса. Потом примените то же правило ко всем репозиториям. Простота — это хорошо.

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

Прежде чем делить еще больше сервисов, добавьте CI-проверки, которые рано ловят расхождение. Держите их маленькими и строгими:

  • устанавливайте проект так же, как это делает production
  • запускайте тесты против установленного пакета, а не против сырого дерева исходников
  • валите сборку, если скрипты импортируют код из родительских или соседних путей
  • требуйте bump версии, когда меняется код пакета

Если команда постоянно скатывается обратно в импортные хаки, копипасту модулей или неясные правила версий, может помочь внешний перезапуск. Oleg Sotnikov работает со стартапами и небольшими компаниями как Fractional CTO и помогает настраивать правила упаковки, версионирование и AI-поддерживаемые рабочие процессы разработки, которые людям реально соблюдать без постоянного контроля.

Начните с одного репозитория, одного пакета и одного CI-ограничителя. Если это продержится две недели, переносите схему дальше.