Структура проекта на Python: пакеты, модули, точки входа
Плоский проект с одним файлом рано или поздно превращается в кашу из циклических импортов и запусков через python script.py из корня. Разбираем, как раскладывать пакеты, где держать точку входа и почему src-layout спасает от импортов-призраков.

Проект начинается с одного файла main.py. Через полгода в нём двенадцать модулей, три из них импортируют друг друга по кругу, тесты запускаются только из корня, а после установки пакета через pip оказывается, что половина импортов больше не находит модули. Знакомая картина. Разбираем, как раскладывать код так, чтобы импорты работали и в разработке, и после установки, где держать точку входа и зачем нужен src/. Всё для Python 3.11+ и упаковки через pyproject.toml.
Это разбор компромиссов, а не пошаговый гайд. Если вы только запускаете первый скрипт, часть про упаковку и entry points пока избыточна — вернётесь, когда проект перерастёт один файл.
Задача и почему очевидное решение подводит
Очевидный вариант — сложить все .py-файлы в корень репозитория рядом с README.md и запускать python app.py. Работает, пока вы запускаете строго из корня. Ломается в трёх местах сразу:
- Запуск скрипта напрямую (
python app.py) добавляет вsys.pathкаталог самого скрипта, а запуск как модуля (python -m app) — текущий рабочий каталог. Это разные пути, и относительные импорты ведут себя по-разному. - После
pip installваш пакет живёт вsite-packages, а не в корне репозитория. Импорт, который работал у вас, у пользователя падает сModuleNotFoundError. - Тесты в каталоге
tests/случайно импортируют код из корня, а не установленную версию пакета. Вы тестируете исходники, а пользователь получает собранный wheel — и это не всегда один и тот же код.
Пакет и модуль — не одно и то же
Модуль — один файл .py. Пакет — каталог с модулями, который Python распознаёт как единое пространство имён. С Python 3.3 каталог считается пакетом даже без __init__.py (это неявный namespace package), но для обычного проекта файл __init__.py стоит держать: он делает пакет «регулярным», управляет тем, что экспортируется, и убирает неоднозначность при сборке.
Два раскладки: flat-layout и src-layout
Есть два способа разложить исходники, и спор между ними идёт годами. Оба поддержаны инструментами упаковки официально.
Flat-layout
myproject/
├── pyproject.toml
├── README.md
├── mypackage/
│ ├── __init__.py
│ ├── core.py
│ └── cli.py
└── tests/
└── test_core.pyПакет лежит прямо в корне. Плюс — короче путь, меньше вложенности. Минус — критичный: когда вы запускаете что-либо из корня репозитория, Python видит каталог mypackage/ и импортирует исходники напрямую, минуя установку. Вы можете месяцами не замечать, что пакет собирается неправильно, — тесты-то проходят, потому что берут локальные файлы.
Src-layout
myproject/
├── pyproject.toml
├── README.md
├── src/
│ └── mypackage/
│ ├── __init__.py
│ ├── core.py
│ └── cli.py
└── tests/
└── test_core.pyПакет спрятан под src/. Из корня его напрямую не импортировать — src не в sys.path. Это заставляет вас установить пакет (обычно pip install -e .) прежде чем запускать тесты. И вот тут ловушка вскрывается сразу: если что-то забыли положить в сборку, тесты упадут у вас, а не у пользователя через месяц.
Src-layout ценен не структурой каталогов, а тем, что он ломает молчаливое чтение исходников в обход установки. Вы тестируете ровно то, что уедет в PyPI.
Что выбирать
| Критерий | flat-layout | src-layout |
|---|---|---|
| Пакет для распространения (PyPI) | рискованно | рекомендуется |
| Внутренний скрипт, один пакет | приемлемо | избыточно |
| Гарантия «тестирую то, что собираю» | нет | да |
| Глубина вложенности | меньше | больше на один уровень |
Правило простое: библиотека, которую кто-то установит, — src-layout. Локальный инструмент, который никогда не выйдет за пределы репозитория, — можно flat, экономия на вложенности того стоит.
Точка входа: три способа и когда какой
Точка входа — это то, что запускается, когда пользователь набирает команду или python -m. Способов запуска три, и они не взаимозаменяемы.
1. Файл __main__.py — запуск через python -m
Если положить __main__.py в пакет, работает python -m mypackage. Это надёжнее, чем python src/mypackage/cli.py, потому что импорты пакета резолвятся корректно.
# src/mypackage/__main__.py
from mypackage.cli import main
if __name__ == "__main__":
main()2. Console script в pyproject.toml — установка команды
Самый чистый способ отдать пользователю команду в терминале. После pip install появляется исполняемый файл в PATH:
# pyproject.toml (сборка через setuptools или hatchling)
[project.scripts]
mytool = "mypackage.cli:main"Строка слева от двоеточия — путь импорта, справа — имя вызываемой функции. Функция main должна существовать и вызываться без аргументов. После установки команда mytool запускает именно её.
3. Прямой запуск файла — только для черновиков
Блок if __name__ == "__main__": в конце модуля работает при python cli.py, но зависит от того, откуда вы запустили. Для разового прогона годится, для распространяемого инструмента — нет.
Импорты внутри пакета: абсолютные против относительных
Внутри пакета можно импортировать двумя стилями. Абсолютный:
# src/mypackage/cli.py
from mypackage.core import processОтносительный:
# src/mypackage/cli.py
from .core import processОба валидны. PEP 8 рекомендует абсолютные для читаемости: по строке видно, откуда модуль. Относительные короче и переживают переименование верхнего пакета без правок. Практическое правило: внутри одного пакета допустимы относительные, между разными частями большого проекта — абсолютные. Главное — не смешивать оба стиля для одного и того же импорта в разных файлах, иначе при рефакторинге запутаетесь.
Циклический импорт — что делать
Если a.py импортирует b.py, а b.py — a.py, вы получите либо ImportError при загрузке, либо None вместо ожидаемого объекта. Три выхода по возрастанию цены:
- Вынести общий код в третий модуль, от которого зависят оба, — самый чистый вариант.
- Импортировать не сам объект, а модуль целиком (
import mypackage.b) и обращатьсяmypackage.b.func— тогда связь резолвится в момент вызова, а не загрузки. - Перенести импорт внутрь функции, где он реально нужен. Работает, но это признак того, что границы модулей проведены неудачно.
Где ломается на практике
- ModuleNotFoundError после установки, хотя локально всё работало. Классика flat-layout: тесты читали исходники, а собранный wheel не включил подпакет. Проверьте секцию поиска пакетов в
pyproject.tomlи соберитеpython -m build, распакуйте wheel и посмотрите, что внутри. - attempted relative import with no known parent package. Вы запустили модуль напрямую как скрипт (
python cli.py), а он использует относительные импорты. Запускайте черезpython -m mypackage.cliиз корня. - Тесты не видят пакет. Забыли
pip install -e .в окружении, где гоняете тесты. Editable-установка кладёт ссылку на исходники, и импорты резолвятся как у пользователя. - Данные (шаблоны, конфиги) не попали в сборку. Нетекстовые файлы внутри пакета нужно явно объявить как package data в конфигурации сборки. Иначе после установки код есть, а файлы рядом с ним — нет.
Что делать дальше
Заведите pyproject.toml с секцией [project] и системой сборки, переведите проект на src-layout, поставьте пакет через pip install -e . в чистое виртуальное окружение и прогоните тесты — если они прошли из окружения, а не из корня, раскладка верна. Дальше смотрите в сторону инструмента для запуска (например, консольный entry point вместо голого скрипта) и один раз соберите wheel, распакуйте его и глазами проверьте, что внутри лежит ровно то, что вы ожидаете. Точный синтаксис секций [project], [project.scripts] и объявления package data сверяйте с актуальной документацией выбранного бэкенда сборки — setuptools и hatchling описывают это чуть по-разному.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Python Packaging User Guide — src layout vs flat layout, Python docs — Modules and Packages
Частые вопросы
Нужен ли __init__.py в каждом каталоге пакета?
А если у меня flat-layout и всё работает — обязательно переходить на src?
Что делать, если импорты работали, а после сборки пакета падают?
Можно ли запускать модуль просто через python файл.py?
Абсолютные или относительные импорты внутри пакета?
Как избавиться от циклического импорта?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.