Learning Platform
Урок 03.05 · 18 мин
Начальный
src-layoutPackagingEditable installConsole scriptsAirflow
Airflow: как Python-пакеты становятся DAG-задачами

От скрипта к пакету

В прошлом уроке мы научили проект читать конфиг из .env. Сейчас сделаем последний шаг — превратим набор .py-файлов в полноценный

Python-пакет
. Без этого ваш код не сможет:

  • Импортироваться из 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/. Лишний уровень вложенности. Зачем?

flat-layout vs src-layout

Внешне отличие в одной папке, по сути — в гарантиях изоляции от системного Python.

flat-layoutпакет в корне
my-etl/
pyproject.toml
my_etl/пакет рядомPython случайно увидит его при импорте из cwd
tests/
src-layoutпакет в src/
my-etl/
pyproject.toml
src/
my_etl/изолированИмпорт работает только через установку — гарантия чистого окружения
tests/

Почему src-layout

В flat-layout есть тонкая проблема: Python автоматически добавляет текущую папку в

sys.path
. Когда вы делаете python tests/test_extract.py из корня проекта, Python видит my_etl/ в текущей папке и импортирует её — даже если вы не установили пакет.

Звучит как удобство, но это даёт три неприятных эффекта:

  1. Тесты проходят локально, но падают в CI — потому что CI запускается из другой папки, и my_etl/ уже не виден.
  2. Импортируется неустановленная версия — вы внесли изменение в extract.py, не запустили uv pip install -e ., и тесты прошли с новым кодом. А в реальной среде установлена старая версия, и логика разъехалась.
  3. Конфликты имён — если в системном 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 .

Чтобы пакет был импортируем — его нужно установить. Но в разработке вы постоянно правите код, и переустанавливать после каждого изменения — невозможно. Решение —

editable install
:

uv pip install -e .

-e значит «editable». Что делается:

  1. В .venv/lib/python3.13/site-packages/ создаётся не копия my_etl/, а ссылка на src/my_etl/.
  2. Любое изменение в src/my_etl/extract.py сразу видно через import my_etl.extract — никакой переустановки.
  3. Метаданные пакета (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

Это делается через

entry points
:

[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

Airflow
запускает Python-код через несколько механизмов. Самые частые:

  • 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.

TIP

В 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) — это первое, что нужно дописать сразу после создания проекта.

Упражнение

  1. Создайте новый проект mini-etl:
mkdir mini-etl
cd mini-etl
uv init --python 3.13
  1. Перестройте в src-layout. Удалите main.py из корня. Создайте структуру:
mini-etl/
├── pyproject.toml
├── src/
│   └── mini_etl/
│       ├── __init__.py
│       └── main.py
└── tests/
    └── test_main.py
  1. В 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))
  1. Дополните 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"]
  1. Установите как editable:
uv sync
  1. Проверьте:
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: типы, коллекции, файлы, путь данных через программу.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5