Прямой ответ
Стандартная современная раскладка — src-layout: код в src/<пакет>/, тесты рядом с корнем (tests/), конфигурация в pyproject.toml ([project], [tool.pytest], [tool.ruff] — один файл вместо setup.py+setup.cfg+requirements-зоопарка — упаковка); главное свойство src-layout: пакет не импортируется из текущей директории — только установленный (pip install -e .) — ошибки упаковки/импортов вскрываются сразу, а не «у пользователя не работает» (flat-layout — mypkg/ в корне — pytest, запущенный из корня, импортирует локальную копию, а не установленную — расхождение «тесты зелёные, установка сломана» — классический ложный сигнал; src-layout устраняет его конструкцией). Импорты — абсолютные (from mypkg.services.orders import create_order) — относительные (from . import x) допустимы внутри пакета, но абсолютные читаются однозначнее и не ломаются при переносе модуля; PEP 8 и здравый смысл против sys.path-хаков и «запуска изнутри пакета» (python src/mypkg/cli.py — относительные импорты падают — правильно: pip install -e . + точка входа python -m mypkg / console_script — CLI-практика). Внутренняя структура пакета — по слоям/доменам, с одним направлением зависимостей: api/интерфейсы → services (бизнес-логика) → repository/db (доступ к данным) → infra/clients (внешние клиенты); правило — зависимости внутрь, не наружу (services не знают про FastAPI/SQLAlchemy-детали — интерфейсы/протоколы (типизация) на границах — смена фреймворка/БД не переписывает бизнес-слой); циклические импорты — симптом нарушенных границ (лечение — вынос общего в нижний слой/интерфейс, не «импорт внутри функции» как маскировка). И утилитарный минимум: __init__.py (явные пакеты — пустые ок; re-export публичного API — from .orders import create_order + __all__ — удобный фасад), py.typed (маркер типизации для потребителей — mypy их увидит), конфиг-объект в одном месте (pydantic-settings) — не os.getenv по всем модулям.
Раскладка: файлы, каталоги, что где
Канонический набор: src/mypkg/ — код (подпакеты: api/ — роуты/контроллеры (тонкие — валидация+вызов сервиса), services/ — бизнес-логика (ядро, максимально без фреймворка), db/ или repositories/ — модели/доступ к данным (SQLAlchemy-слой), clients/ — внешние HTTP/очереди (httpx), schemas/ — Pydantic-модели входа/выхода (валидация), core/ — конфиг/логирование/исключения (логи); tests/ — зеркалит структуру (tests/services/test_orders.py — найти тест по модулю за секунды; unit/integration/e2e — подкаталоги или маркеры — pytest-структура); pyproject.toml — метаданные+зависимости+инструменты (ruff/mypy/pytest-конфиги — ruff); README.md, Dockerfile (сборка), .env.example (имена без значений — секреты), alembic/ (миграции) если есть БД, .github/workflows/ (CI); что в корне не живёт: код вне src (кроме тестов), requirements.txt как источник истины (pyproject+лок-файл — requirements — легаси-совместимость/CI-артефакт), setup.py (не нужен с современным backend'ом — hatchling/setuptools через pyproject); «скрипты в корне» — в scripts/ (и не путать с точками входа пакета — скрипт ≠ API). Масштабирование раскладки: один пакет до ~50–100 модулей; дальше — подпакеты по доменам (orders/, billing/ — вертикальные срезы со своими api/services/schemas — границы совпадают с ответственностью команд — монорепо-контекст) или разделение на пакеты/сервисы (микросервисы — когда организационно оправдано); «плоский пакет на 200 модулей» — навигация по алфавиту вместо смысла — декомпозировать по доменам раньше, чем станет больно.
Импорты: правила и механика Python
Как Python находит модули: sys.path (cwd/скрипт-директория + site-packages установленного) — отсюда следствия: из корня проекта пакет виден «даром» (flat-layout-иллюзия), установленный — виден везде (src-layout требует pip install -e . — разовая команда в README/onboarding — окружения); абсолютные импорты — от имени пакета (from mypkg.db.session import get_session) — работают одинаково из любого места, grep-однозначны, рефакторинг-устойчивы; относительные (from ..db import session) — короче внутри подпакета, но: ломаются при запуске модуля как скрипта, путаются на глубокой вложенности — команда выбирает стиль и фиксирует линтером (ruff/isort-правила — ruff — сортировка/стиль импортов автоматизированы); циклические импорты (A→B→A — ImportError/partial module — возникает при взаимных ссылках слоёв): лечение структурное — общий код ниже по слоям, интерфейсы вместо конкретных классов, композиция вместо наследования через слои; «импорт внутри функции» — обход симптома (работает, но прячет архитектурную проблему — допустим для тяжёлых опциональных зависимостей — ленивость, не для циклов); точка входа: if __name__ == "__main__": main() в __main__.py (python -m mypkg работает) + console_scripts в pyproject (mypkg = "mypkg.cli:main" — команда mypkg после установки — упаковка); запуск «файлом» (python src/mypkg/main.py) — антипаттерн (пакетный контекст теряется — относительные импорты/данные пакета — ломаются — «у меня локально работает» — потому что запускает не так, как задокументировано); импорт-тайм дисциплина — тяжёлые импорты/подключения на уровне модуля — зло (импорт модуля открывает БД-соединение — тесты/CLI-справка платят за это — ленивая инициализация/фабрики (фабрики в тестах) — соединения создаются вызовом, не импортом — конфиг-старт валидирует на старте приложения, не модуля).
Практический сценарий: распутывание проекта-«шара»
Исходник: веб-сервис 3 года роста — flat-layout, 80 модулей в одном каталоге app/, импорты перемешаны (роуты зовут SQL напрямую, утилиты импортируют модели, круговые импорты обходятся «импортом в функции» в 14 местах), тесты запускаются «из корня определённой командой», установка пакетом никогда не проверялась (деплой — rsync каталога). Разбор за две итерации: (1) инвентаризация зависимостей — скрипт по AST построил граф импортов (80 узлов, 23 цикла — картина ужаса впервые визуализирована — до этого «все знают, что всё связано»); (2) целевая структура: src-layout, слои api/services/repositories/clients/core — перенос модулей по слоям (механический git mv + правка импортов — отдельный PR без изменения логики — ревью читает только переносы — логика-PR отдельно — разделяй рефакторинг и изменения); (3) циклы — разрыв по схеме: утилиты, нужные всем — в core (нижний слой); взаимные ссылки services — через протоколы (typing.Protocol — интерфейс в core, реализации в services — mypy проверяет соответствие без импорта реализации); 14 «импортов в функции» — 11 устранены структурно, 3 остались законной ленивостью (тяжёлые ML-библиотеки в опциональном пути); (4) границы — роуты перестали содержать SQL (вызов service-функций — запросы в repositories), services — без импортов fastapi (запрос/ответ — схемы, не Request-объекты — тестируемость без HTTP); (5) упаковка: pyproject + pip install -e . в README/CI, точка входа console_script + python -m, CI ставит пакет и гоняет тесты из произвольной директории («установленный пакет работает» — проверяется впервые — найдены два битых импорта данных, невидимые в rsync-деплое); (6) тесты — зеркальная структура + фикстуры уровня слоя (pytest — conftest по подкаталогам); проверка: mypy по слоям (services — самый чистый — граница без фреймворка помогла), ruff — стиль импортов автоформатирован, время импорта CLI — 1.8 с → 0.3 с (тяжёлые импорты стали ленивыми — побочный выигрыш). Итог: новый разработчик ориентируется за день (слои предсказуемы), циклов импортов ноль, установка пакетом работает и проверяется CI, замена одного внешнего клиента заняла полдня (clients-слой изолирован — раньше — «щупать весь app/»); правило команды в ADR: «зависимости внутрь, бизнес-слой без фреймворка, новый модуль — в свой слой/домен».
FAQ
src-layout обязателен или flat тоже нормально?
Не обязателен: flat-layout (пакет в корне) легитимен и популярен (многие известные проекты), разница — в гарантии: src-layout заставляет тестировать установленный пакет (ошибки упаковки видны сразу), flat — позволяет «зелёным тестам» маскировать поломку установки (импорт из cwd); для приложений (не библиотек) риск ниже — но и цена src-layout — одна строка pip install -e . в setup-инструкции; новый проект — src-layout (дефолт рекомендаций packaging.python.org), существующий flat без проблем — миграция по остаточному принципу (не ломает ничего, кроме привычки — делать вместе с другим рефакторингом, не отдельно).
Куда класть общие модели — schemas или домен? Зависит от природы: транспортные схемы (вход/выход API — Pydantic) — schemas/слой API (меняются с контрактом — версии API); доменные сущности (бизнес-объекты — dataclass/pydantic) — в services/домене (живут дольше контрактов — transport ≠ domain — смешение в одном классе — источник «изменили API-ответ — сломалась логика»); ORM-модели — db/repositories (деталь хранения — наружу не текут — маппинг ORM↔домен на границе слоя); «один класс на всё» (ORM = схема API = домен) — работает в маленьких проектах, платит связностью в больших — разделять по факту изменений.
Как бороться с разрастанием utils.py? utils/misc/common — гравитационная яма (всё «непонятно куда» — через год — свалка с циклическими импортами и нулевой связанностью); профилактика: правило «утилита живёт рядом с доменом, которому служит» (форматтер дат заказов — в orders/), общий — только действительно кросс-доменное (логирование/конфиг — core/), размер-триггер (utils > 10 функций — разделить по смыслу — даты/строки/валидаторы — именование по содержанию, не по остаточному принципу); ревью-вопрос «почему это в utils?» — дешёвый барьер; существующую свалку — разбирать инкрементально (техдолг-подход — при касании кода — переносить затронутое).
Нужен ли init.py и что в нём писать?
Для явных пакетов — нужен (пустой файл — ok; namespace-пакеты без init — специфичны, в приложениях не нужны); содержимое — по выбору: пустой (импорты полными путями — явность, grep-дружелюбность — рекомендация для больших кодовых баз) или фасад (re-export публичного API: from .orders import create_order, cancel_order + __all__ — потребители пишут from mypkg.services import create_order — короткий стабильный интерфейс, внутренняя структура вправе меняться); фасадная цена — риск циклов при агрессивном re-export (подпакет тянет всё при любом импорте — импорт-тайм растёт — лень/точечность решают) и дублирование «правды об экспорте» (all против фактических импортов — следить); в приложении обычно достаточно пустых init + точечных фасадов на публичных границах (клиентах пакета/SDK).
Источники: packaging.python.org (src vs flat), документация Python (import system), PEP 8 — ссылки в шапке.