От скрипта к пакету
В прошлом уроке мы научили проект читать конфиг из .env. Сейчас сделаем последний шаг — превратим набор .py-файлов в полноценный
- Импортироваться из Airflow в production.
- Запускаться как CLI-команда (
my-etl runвместоpython src/main.py). - Устанавливаться к коллеге одной командой.
- Загружаться на внутренний PyPI компании.
Эта тема кажется бюрократической, но за час практики она экономит сотни часов на больших проектах. Junior DE, который пришёл в команду с разваленным «бизнес-логика в одной папке, скрипты в другой» — увидит много страданий.
Два layout’а: flat и src
Когда вы создаёте проект через uv init, по умолчанию вам делают flat-layout — самый простой:
my-etl/
├── pyproject.toml
├── main.py
└── helpers.py
Это работает для скриптов из пяти файлов. Но как только проект начинает иметь свою папку с модулями, появляется развилка.
Flat-layout:
my-etl/
├── pyproject.toml
├── my_etl/
│ ├── __init__.py
│ ├── extract.py
│ ├── transform.py
│ └── load.py
└── tests/
└── test_extract.py
Пакет my_etl/ лежит прямо в корне рядом с pyproject.toml. Кажется логичным.
src-layout (тот, что официально рекомендуется в 2026):
my-etl/
├── pyproject.toml
├── src/
│ └── my_etl/
│ ├── __init__.py
│ ├── extract.py
│ ├── transform.py
│ └── load.py
└── tests/
└── test_extract.py
Пакет лежит внутри папки src/. Лишний уровень вложенности. Зачем?
Внешне отличие в одной папке, по сути — в гарантиях изоляции от системного Python.
Почему src-layout
В flat-layout есть тонкая проблема: Python автоматически добавляет текущую папку в
sys.pathpython tests/test_extract.py из корня проекта, Python видит my_etl/ в текущей папке и импортирует её — даже если вы не установили пакет.
Звучит как удобство, но это даёт три неприятных эффекта:
- Тесты проходят локально, но падают в CI — потому что CI запускается из другой папки, и
my_etl/уже не виден. - Импортируется неустановленная версия — вы внесли изменение в
extract.py, не запустилиuv pip install -e ., и тесты прошли с новым кодом. А в реальной среде установлена старая версия, и логика разъехалась. - Конфликты имён — если в системном Python есть пакет с тем же именем (например,
tests/), может загрузиться чужой.
src-layout физически не даёт случайно импортнуть неустановленный пакет: cwd — это my-etl/, а пакет лежит в my-etl/src/my_etl/. Python в src/ не пойдёт без явного указания. Чтобы импорт заработал — нужно установить пакет (см. ниже про editable install). И теперь локальный запуск и CI ведут себя одинаково: оба видят установленную версию.
С 2023 года официальная рекомендация Python Packaging Authority — src-layout. Все приличные новые проекты так и делают.
Структура src-layout с pyproject.toml
Полный минимальный проект:
my-etl/
├── pyproject.toml
├── README.md
├── .env.example
├── .gitignore
├── src/
│ └── my_etl/
│ ├── __init__.py
│ ├── extract.py
│ ├── transform.py
│ ├── load.py
│ └── main.py
└── tests/
├── __init__.py
└── test_extract.py
В pyproject.toml нужно указать hatchling, где искать пакет:
[project]
name = "my-etl"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"pydantic-settings>=2.5",
"requests>=2.32",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/my_etl"]
Секция [tool.hatch.build.targets.wheel] явно говорит: пакет, который попадает в wheel, — это src/my_etl/. Без этой секции hatchling попробует найти пакет автоматически и в простых случаях справится, но для надёжности лучше написать явно.
__init__.py в src/my_etl/ — обязателен. Это файл, превращающий папку в пакет. Часто он пустой, иногда содержит реэкспорт ключевых символов:
# src/my_etl/__init__.py
from my_etl.extract import fetch_users
from my_etl.transform import normalize
__version__ = "0.1.0"
__all__ = ["fetch_users", "normalize", "__version__"]
После этого from my_etl import fetch_users работает короче, чем from my_etl.extract import fetch_users.
Editable install: uv pip install -e .
Чтобы пакет был импортируем — его нужно установить. Но в разработке вы постоянно правите код, и переустанавливать после каждого изменения — невозможно. Решение —
uv pip install -e .
-e значит «editable». Что делается:
- В
.venv/lib/python3.13/site-packages/создаётся не копияmy_etl/, а ссылка наsrc/my_etl/. - Любое изменение в
src/my_etl/extract.pyсразу видно черезimport my_etl.extract— никакой переустановки. - Метаданные пакета (
pyproject.toml) тоже считываются —pip listпоказываетmy-etl 0.1.0.
Это базовый рабочий режим разработки.
В uv начиная с 2024 года есть ещё более удобный способ — uv sync в проекте с заданным [project] пакетом автоматически делает editable install:
uv sync
— и пакет уже установлен в .venv/ в editable режиме. uv pip install -e . нужен только если хочется явный контроль.
После установки import my_etl работает откуда угодно — из тестов, из CLI, из Airflow:
$ uv run python -c "import my_etl; print(my_etl.__version__)"
0.1.0
$ cd /tmp
$ uv run --project ~/projects/my-etl python -c "import my_etl; print(my_etl.__version__)"
0.1.0
Console scripts: ваш код как CLI
Часто хочется запускать ETL не через python -m my_etl.main, а как обычную команду:
my-etl run --date 2026-05-13
Это делается через
[project.scripts]
my-etl = "my_etl.main:cli"
Левая часть — имя команды, правая — путь до функции cli в модуле my_etl.main.
В src/my_etl/main.py:
import sys
def cli() -> None:
"""Точка входа для my-etl CLI."""
args = sys.argv[1:]
if not args:
print("Usage: my-etl <command>")
return
command = args[0]
if command == "run":
print("Running ETL...")
else:
print(f"Unknown command: {command}")
if __name__ == "__main__":
cli()
После uv pip install -e . (или uv sync) появляется команда:
$ uv run my-etl run
Running ETL...
uv создаёт скрипт-обёртку в .venv/bin/my-etl, который запускает my_etl.main:cli.
В production, когда пакет установлен в системный Python (внутри Docker-образа), команда my-etl доступна напрямую без uv run.
Зачем это для Airflow
- PythonOperator — импортирует функцию из вашего пакета и вызывает её.
- BashOperator — запускает произвольную shell-команду.
Без установленного пакета PythonOperator не сможет сделать from my_etl.extract import fetch_users. То есть на машинах с Airflow ваш пакет должен быть установлен как полноценный — pip install my-etl или pip install -e /path/to/my-etl в Docker-образе.
Пример DAG’а, который использует ваш пакет:
# dags/my_etl_dag.py — лежит в Airflow, не в вашем пакете
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator
from my_etl.extract import fetch_users
from my_etl.load import load_to_postgres
with DAG(
"my_etl_daily",
start_date=datetime(2026, 1, 1),
schedule="@daily",
catchup=False,
) as dag:
extract = PythonOperator(task_id="extract", python_callable=fetch_users)
load = PythonOperator(task_id="load", python_callable=load_to_postgres)
extract >> load
Чтобы это работало, на Airflow-worker’е должен быть установлен ваш пакет. Обычно через Docker-образ:
FROM apache/airflow:2.10.0-python3.13
COPY pyproject.toml uv.lock /tmp/my-etl/
COPY src /tmp/my-etl/src/
RUN pip install /tmp/my-etl
И тут src-layout снова окупается: пакет ставится один раз через pip install, дальше Airflow его импортирует так же, как любой другой пакет с PyPI.
В Airflow часто используют альтернативу — монтирование папки src/ в worker как volume. Это удобно для разработки, но в production всегда устанавливайте пакет через pip install. Editable install в production — это нестабильно.
README.md: минимальный набор
Все эти красивости не имеют смысла, если коллега, открывший репозиторий, не знает, что с ним делать. README — это первое и часто единственное, что читают.
Минимально полезный README:
# my-etl
ETL-пайплайн для выгрузки пользователей из API X и загрузки в Postgres Y.
## Установка
uv tool install uv # если ещё нет
git clone [email protected]:org/my-etl.git
cd my-etl
uv sync
cp .env.example .env # заполнить значениями
## Запуск
uv run my-etl run --date 2026-05-13
## Разработка
uv run pytest # тесты
uv run ruff check # линтер
uv run ruff format # форматтер
pre-commit install # хуки
## Архитектура
src/my_etl/extract.py — извлечение данных из API
src/my_etl/transform.py — нормализация
src/my_etl/load.py — запись в Postgres
src/my_etl/main.py — CLI entry point
Это «минимум» от которого не страдает ни один читающий. Можно дальше добавлять секции — диаграмма архитектуры, схема БД, FAQ, контакты. Но никогда не оставляйте README пустым (как делает uv init) — это первое, что нужно дописать сразу после создания проекта.
Упражнение
- Создайте новый проект
mini-etl:
mkdir mini-etl
cd mini-etl
uv init --python 3.13
- Перестройте в src-layout. Удалите
main.pyиз корня. Создайте структуру:
mini-etl/
├── pyproject.toml
├── src/
│ └── mini_etl/
│ ├── __init__.py
│ └── main.py
└── tests/
└── test_main.py
- В
src/mini_etl/main.py:
def hello(name: str) -> str:
return f"Hello, {name}!"
def cli() -> None:
import sys
name = sys.argv[1] if len(sys.argv) > 1 else "world"
print(hello(name))
- Дополните
pyproject.toml:
[project]
name = "mini-etl"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
[project.scripts]
mini-etl = "mini_etl.main:cli"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/mini_etl"]
- Установите как editable:
uv sync
- Проверьте:
uv run mini-etl Anna
uv run python -c "from mini_etl.main import hello; print(hello('Sergey'))"
Критерии приёмки:
uv run mini-etl AnnaпечатаетHello, Anna!.- Импорт из любой точки в проекте работает.
- Создайте папку
tests/и положитеtest_main.py:
from mini_etl.main import hello
def test_hello() -> None:
assert hello("Anna") == "Hello, Anna!"
— uv run --with pytest pytest должен пройти из корня проекта.
Резюме модуля
За эти пять уроков вы получили готовый шаблон современного DE-проекта 2026 года:
uvуправляет Python-версией, окружением и зависимостями.pyproject.toml— единый манифест с типизированной конфигурацией всего стека.ruff+pre-commitгарантируют, что в git попадает только чистый код.pydantic-settingsзагружает типизированную конфигурацию из env и.env.- src-layout с editable install позволяет импортировать пакет везде — из тестов, из CLI, из Airflow.
В следующем модуле — ядро Python для Data Engineer: типы, коллекции, файлы, путь данных через программу.