Как выбрать структуру проекта на Python: src-layout, нейминг модулей и где хранить конфиги
Разберём рабочие схемы организации репозитория (src-layout vs flat), правила именования, расположение настроек/секретов и типовые ошибки, которые мешают тестированию и сборке. Поймёте, как подготовить проект под рост и CI.
Содержание
Как выбрать структуру проекта на Python: src-layout, нейминг модулей и где хранить конфиги
Хорошая структура репозитория на Python редко бывает «видна глазом» в первых коммитах. Она проявляется позже: когда проект начинает расти, подключаются тесты, появляется CI, и внезапно выясняется, что код импортируется не так, как вы ожидали; пакеты «текут» из рабочего каталога; конфиги разъезжаются между окружениями; секреты попадают в репозиторий или, наоборот, настолько запутываются, что тесты перестают быть воспроизводимыми.
Ниже разберём практические схемы организации репозитория (включая src-layout и более «плоскую» структуру), правила именования модулей, а также рабочие способы хранения конфигураций и секретов. В конце — список проверок, который помогает подготовить проект к росту и нормальному CI.
Почему структура репозитория важнее, чем кажется
На Python многое решают импорты. В отличие от языков с жёсткой компоновкой (где проектная структура «зацементирована» билд-системой), в Python модуль можно импортировать из разных мест — по правилам sys.path, текущего рабочего каталога, окружения и того, как установлен пакет.
В начальной стадии это обычно не проблема: вы запускаете код из корня репозитория, всё «магически находится», тесты пишутся быстро, конфиги — прямо в файлах рядом со скриптами. Но как только:
- вы добавляете
pip install -e .или сборку в wheel, - запускаете тесты в CI из другого каталога,
- включаете параллельный запуск,
- добавляете линтеры и type-check,
- делаете несколько пакетов в одном репозитории,
— начинают всплывать накопленные компромиссы.
Хорошая структура решает сразу три задачи:
- Предсказуемые импорты при любом способе запуска (локально, в CI, после установки).
- Разделение бизнес-кода и окружения (конфиги и секреты не должны мешать тестам и сборке).
- Управляемость роста: новые модули и настройки появляются в понятных местах.
Выбор схемы: src-layout vs flat
Flat layout: когда код лежит прямо в корне
Типичный «плоский» вариант выглядит так:
my_project/
app/
__init__.py
main.py
tests/
test_main.py
pyproject.toml
Или даже так:
my_project/
src/
??? (нет)
app.py
helpers.py
tests/
Проблема flat layout в импортах: если вы запускаете тесты из корня репозитория, Python может подхватить модули из рабочего каталога, даже если пакет «не установлен» или установлен неправильно. Это приводит к ложноположительным результатам: тесты проходят локально, а в CI ломаются (или наоборот).
Классический симптом: вы замечаете, что import app работает «само собой» и в CI, но в зависимости от настройки PYTHONPATH или команд запуска результат может меняться.
Flat layout допустим для маленьких скриптов и одноразовых утилит. Для библиотек, CLI и проектов, которые будут расти, обычно безопаснее src-layout.
src-layout: предсказуемость через установку пакета
src-layout отделяет исходники от корня репозитория:
my_project/
pyproject.toml
src/
my_project/
__init__.py
cli.py
core/
__init__.py
service.py
tests/
test_service.py
Здесь ключевой момент: пакет my_project лежит внутри src/. Это означает, что импорты, которые видит Python, завязаны на корректную установку/разрешение пакета (через настройки pyproject.toml и pip install -e .), а не на «случайное» наличие директорий в текущем рабочем каталоге.
При правильной настройке это даёт:
- более надёжное тестирование;
- корректную работу инструментов (pytest, mypy, ruff и т.д.);
- предсказуемое поведение после сборки wheel.
Как выглядит правильный pyproject.toml для src-layout
Пример (минимально-репрезентативный) для пакета в src/:
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "my-project"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = []
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
Если вы используете hatchling или poetry, подход аналогичный: важно, чтобы билд-система знала, где исходники пакета. При src-layout это решает проблему импорта «из воздуха».
Когда всё-таки может понадобиться flat layout
Иногда flat layout оправдан:
- репозиторий — набор утилит без единого пакета (каждая утилита автономная);
- вы используете только один entrypoint-скрипт и импортируете из него минимум модулей;
- проект не планирует публикацию пакетов и сложную интеграцию.
Но как только появляются тесты и CI, стоимость компромисса растёт. Практика показывает: даже если вы не планируете публиковать библиотеку, src-layout снижает риски и ускоряет дальнейшую разработку.
Нейминг модулей и пакетов: правила, которые экономят время
Именование пакетов: строчные, без дефисов, единый стиль
Python-пакеты и модули принято именовать snake_case и строчно: my_project, data_loader, http_client.
Избегайте:
MyProject(это будет восприниматься как класс/тип, создаст путаницу);- смешение стилей (
myProject,my-project); - пробелы и дефисы в именах модулей (дефисы не являются валидными идентификаторами Python).
Также избегайте «пакетного» совпадения с именами стандартной библиотеки: модуль email.py, json.py, typing.py — частая причина странных ошибок и теневых импортов.
Именование модулей: по ответственности, а не по формату файла
Вместо:
utils.py,helpers.py(которые со временем превращаются в «корзину» всего подряд), лучше выделять ответственность:logging.py,config_loader.py,date_time.py,http_client.py;validators.pyдля валидаций домена;transformers.pyдля преобразований данных.
Если модуль растёт, он должен разбиваться по функциональным границам, а не по «случайным» добавлениям.
Структура подпакетов: domain, adapters, services — но с дисциплиной
Частый вопрос: как разложить папки внутри src/my_project/?
Один из рабочих подходов — разделить код на «слои» (не строго, но концептуально):
core/(бизнес-логика, доменные сущности, правила);adapters/(интеграции: БД, HTTP, файловая система);services/(оркестрация use-case’ов, если она отличается от домена);api/илиcli/(входные точки: интерфейс команд/HTTP).
Например:
src/
my_project/
core/
__init__.py
domain.py
rules.py
adapters/
__init__.py
db.py
http.py
api/
__init__.py
cli.py
main.py
Ключ: не превращайте эти слова в декоративные папки. Если вы назвали adapters/db.py — там должен быть код конкретного адаптера, а не абстрактный слой без конкретики.
__init__.py: минимализм и осмысленные экспорты
Внутри пакетов нужен __init__.py, чтобы Python считал их пакетами (для «старых» контекстов) и чтобы вы могли управлять экспортом.
Но не делайте from .everything import * в каждом __init__.py. Это усложняет статический анализ и увеличивает шансы на циклические импорты.
Лучше:
- оставлять пустым или с небольшой конфигурацией;
- явно экспортировать ключевые объекты, если это действительно нужно.
Где хранить конфиги: от простого до воспроизводимого и безопасного
Три разные категории: default configs, env configs, secrets
Конфигурация — не один файл «на все случаи». Обычно проект имеет как минимум три типа параметров:
- Default config — базовые значения, которые не являются секретами и подходят «из коробки».
- Environment config — настройки под окружение:
dev,staging,prod(часто тоже не секреты: URL сервера, уровень логирования). - Secrets — токены, пароли, ключи, которые не должны попадать в репозиторий.
Разделение помогает не только безопасности, но и тестируемости: тестам нужны предсказуемые значения без зависимости от внешней среды.
Базовый вариант: конфиги в репозитории, секреты — снаружи
Распространённая структура:
my_project/
src/
my_project/
...
config/
default.toml
logging.toml
pyproject.toml
.env.example
.gitignore
Дальше: в default.toml хранятся не секреты. Секреты задаются через переменные окружения или секрет-хранилища CI.
Файл .env.example полезен как документация:
APP_ENV=dev
DATABASE_URL=postgresql://user:password@localhost:5432/db
SENTRY_DSN=
Но важно: .env.example — не содержит настоящих секретов.
Рекомендованный формат: TOML/YAML + явный loader
Выбор формата часто спорный, но важно не «какой формат модный», а то, чтобы:
- парсинг был детерминированным;
- была внятная схема значений;
- loader возвращал структурированные данные.
Пример: loader из TOML и переопределение через переменные окружения.
src/my_project/config_loader.py:
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
import tomllib # Python 3.11+. Для 3.10 используйте tomli
@dataclass(frozen=True)
class Settings:
app_env: str
log_level: str
database_url: str
def load_settings(*, config_path: str | os.PathLike | None = None) -> Settings:
base_dir = Path(__file__).resolve().parents[2] # .../src/my_project -> repo root
cfg_path = Path(config_path) if config_path else base_dir / "config" / "default.toml"
data: dict = {}
if cfg_path.exists():
data = tomllib.loads(cfg_path.read_text(encoding="utf-8"))
app_env = os.getenv("APP_ENV", data.get("app_env", "dev"))
log_level = os.getenv("LOG_LEVEL", data.get("log_level", "INFO"))
database_url = os.getenv("DATABASE_URL", data.get("database_url", ""))
if not database_url:
# Для библиотеки лучше кидать понятное исключение, чтобы раннее падение было полезным.
raise RuntimeError("DATABASE_URL is not set (env var or config).")
return Settings(app_env=app_env, log_level=log_level, database_url=database_url)
config/default.toml:
app_env = "dev"
log_level = "INFO"
database_url = ""
Да, database_url пустой — намеренно. Мы вынуждаем явно задавать секрет/ресурс через окружение. Для небольших проектов это помогает не «случайно» забыть пароль в репозитории.
Где держать конфиги в дереве проекта: config/ vs src/.../config
Частая ошибка — положить конфиги внутрь src/ как обычные файлы. Это работает до тех пор, пока вы не начинаете собирать package и терять доступ к этим файлам в установке (например, внутри wheel).
Если конфиг — это часть runtime (который нужен в установленном пакете), используйте ресурсы пакета (package resources) или включайте файлы в wheel через настройки сборки.
Если конфиг — это окруженческая «документация» для разработчика, держите его в корне (config/, examples/, settings/). Тесты и dev-скрипты смогут ссылаться на него напрямую, а сборка будет проще.
Практическое правило:
- конфиги, которые меняются от окружения → хранить снаружи
src/и подхватывать loader’ом; - конфиги, которые должны быть в установленном пакете (редкие случаи) → использовать механизм ресурсов пакета.
Секреты: только не в репозитории (и не в тестовых фикстурах “по умолчанию”)
Секреты нужно задавать вне кода: переменные окружения, vault, секрет-хранилища CI/CD.
Минимум:
- добавить
.envв.gitignore; - использовать
.env.exampleкак шаблон.
Пример .gitignore:
.env
*.env
Ещё одна типичная ошибка — падать на отсутствии секретов в момент импорта модуля. Это ломает тесты и инструменты, которые импортируют модуль ради определения функций/классов.
Правило: загрузка настроек и проверка обязательных параметров должны происходить в точке запуска, а не в момент импорта модуля.
Циклические импорты, “магия” и почему CI может ломаться
Ошибка №1: импорты завязаны на рабочий каталог
Если структура flat, или если вы добавляете sys.path руками, тесты могут пройти локально, но сломаться в CI.
В src-layout проблема снижается, потому что импорты должны работать после корректной установки пакета. Это ещё один аргумент выбрать src-layout.
Ошибка №2: конфигурация подхватывается слишком рано
Проверка DATABASE_URL в момент импорта приведёт к ситуации:
- тест импортирует модуль
my_project.core.*; - модуль импортирует
Settingsи сразу требуетDATABASE_URL; - тесты не могут собраться/запуститься, даже если они unit-тесты и не должны ходить в БД.
Решение:
- вызывать
load_settings()только там, где реально нужно; - для тестов — передавать настройки или использовать фикстуры.
Ошибка №3: тесты зависят от реальных файлов конфигов
Если тесты ожидают config/default.toml, а CI не копирует эти файлы — вы получите “File not found”.
Лучше:
- либо хранить тестовые конфиги в репозитории;
- либо генерировать конфиги на лету (временные файлы) и передавать в loader;
- либо в тестах переопределять настройки через окружение.
Пример: тест с временным конфигом.
tests/test_config_loader.py:
from __future__ import annotations
from pathlib import Path
import pytest
from my_project.config_loader import load_settings
def test_load_settings_from_custom_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
cfg = tmp_path / "default.toml"
cfg.write_text(
'app_env = "test"\nlog_level = "DEBUG"\ndatabase_url = "postgresql://test"\n',
encoding="utf-8",
)
# Секрет не должен браться из env, пусть возьмётся из конфига:
monkeypatch.delenv("DATABASE_URL", raising=False)
settings = load_settings(config_path=cfg)
assert settings.app_env == "test"
assert settings.log_level == "DEBUG"
assert settings.database_url == "postgresql://test"
Ошибка №4: нейминг, который конфликтует
Модуль/пакет с именем config.py, tests.py, typing.py иногда начинает конфликтовать с импортами и инструментами.
Правило простое: делайте имена осмысленными и специфичными: config_loader.py, test_utils.py, typing_helpers.py.
Подготовка к росту и CI: что проверить до того, как “полетит”
Ниже — практический чеклист, который стоит прогнать перед добавлением сложной инфраструктуры.
1) Пакет импортируется после установки
Идея: тесты и CLI должны работать одинаково после pip install -e . и при запуске из репозитория.
Проверьте локально:
python -m pip install -e .
python -c "import my_project; print(my_project.__file__)"
Если импорт “попадает” не туда — вы не привязаны к реальному пакету.
2) Тесты не требуют секретов “на уровне импорта”
Запустите:
pytest -q
в среде, где переменные окружения отсутствуют (или хотя бы частично отсутствуют). Если тесты падают ещё до выполнения — значит, где-то идёт ранняя загрузка настроек.
3) Конфиги можно переопределить для тестов
Хорошая минимальная архитектура:
load_settings(config_path=...);- или отдельный слой создания settings для тестов.
4) Не полагайтесь на текущую директорию (cwd)
В Python часть проблем появляется из-за обращения к файлам по относительным путям: open("config/default.toml").
Вместо этого используйте Path(__file__) и расчёт корня репозитория (как в примере loader’а). Либо передавайте пути явно из entrypoint.
5) Структура соответствует сборке
Если вы используете src-layout, убедитесь, что билд-система корректно находит пакеты. Иначе сборка wheel может не включить то, что вы думаете, а CI при установке будет другой версией кода.
Практический пример “зрелого” дерева проекта
Один из рабочих вариантов структуры для Python-приложения/CLI:
my_project/
pyproject.toml
README.md
src/
my_project/
__init__.py
cli.py
config_loader.py
core/
__init__.py
service.py
adapters/
__init__.py
db.py
tests/
__init__.py
test_service.py
test_config_loader.py
config/
default.toml
.env.example
.gitignore
Ключевые свойства:
- код живёт в
src/, импорты стабильны; - конфиги в
config/с понятной структурой; - секреты задаются через env;
- тесты могут подменять конфиги через временные пути/монкипатчинг.
Подводные камни: типовые решения, которые потом приходится переделывать
“Сейчас положим всё в utils.py”
Потом это превращается в пакет из взаимозависимых функций, которые тянут конфиги, БД, внешние сервисы и усложняют тестирование. Разделяйте по ответственности сразу — это дешевле, чем разбирать монолит позже.
“Конфиги прямо рядом с кодом”
Если положить config/default.toml в src/my_project/config/default.toml, вы столкнётесь с вопросом: а как вы доставите этот файл при установке? Либо используете package resources, либо держите конфиги в корне репозитория и грузите относительными путями от корня — но тогда нужна согласованность с тем, как вы запускаете проект.
“Секреты в .env и всё локально”
Это работает, пока кто-то случайно не закоммитит .env. Делайте безопасные дефолты: в .env.example — пустые/заглушки, реальные секреты — в CI/локальной настройке.
Вывод: выбирать структуру под предсказуемость, а не под удобство “сейчас”
src-layout обычно даёт лучшую предсказуемость импортов и снижает риск «локально работает, в CI нет». Нейминг модулей и дисциплина разделения ответственности помогают избежать циклических зависимостей и “корзин” вроде utils.py. А конфигурации нужно рассматривать как систему: defaults и env override отдельно, секреты — снаружи, загрузка — в точке запуска, а не на этапе импорта.
Если хотите углубиться в практику организации кода и инструментальные аспекты (tests, packaging, CI-friendly структура), полезно пройти отдельную методику обучения — например, курс по этой тематике можно посмотреть здесь: [ /course/ ].
Главная мысль: структура репозитория — это часть архитектуры проекта. И именно она чаще всего определяет, как быстро вы сможете добавлять функции без постоянных правок импорта, настройки тестов и мучительного дебага в CI.
Комментарии
Пока нет комментариев