Files
airflow-manual/airflow-docker/dags/csv_to_postgres.py
T
ddadminandClaude Opus 4.6 72d5968d01 refactor(dags): перенесён pandas в функции, исправлен баг occupations, добавлены docstring
- Зачем:
  - обратная связь от менти: pandas в шапке DAG замедляет парсинг scheduler,
    отсутствие описаний затрудняет изучение, баг с неиспользуемой переменной.
- Что:
  - import pandas as pd перенесён из шапки внутрь функций (file_operations, csv_to_postgres, data_processing).
  - исправлен баг: переменная occupations в file_operations_dag.py теперь включена в DataFrame.
  - добавлены docstring с описанием шагов DAG для csv_to_postgres.py, csv_to_postgres_dq.py, data_processing_dag.py.
  - расширен docstring file_operations_dag.py.
  - удалён educational-setup-plan.md (технический файл, не для менти).
- Проверка:
  - airflow DagBag: все 8 DAG загружаются без ошибок.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:50:55 +03:00

188 lines
6.6 KiB
Python

"""
DAG для загрузки CSV-данных в PostgreSQL
Уровень: Средний
Этот DAG демонстрирует типичный паттерн загрузки данных из файлов в БД:
1. Создание целевой таблицы public.orders (если не существует)
2. Генерация CSV с заказами (order_id, order_ts, customer_id, amount) через pandas
3. Предпросмотр CSV (head + describe) для контроля
4. Загрузка в PostgreSQL через временную таблицу и anti-join (защита от дубликатов)
Результат: данные в таблице public.orders учебной БД postgres_training.
"""
from __future__ import annotations
import logging
import os
import random
from datetime import UTC, datetime, timedelta
from pathlib import Path
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from airflow import DAG
POSTGRES_CONN_ID = "postgres_training"
def _get_conn():
return PostgresHook(postgres_conn_id=POSTGRES_CONN_ID).get_conn()
CSV_DIR = Path(os.getenv("CSV_DIR", "/opt/airflow/data/output"))
CSV_ROWS = int(os.getenv("CSV_ROWS", "1000"))
def _create_table() -> None:
"""Создаёт таблицу public.orders, если она ещё не существует."""
ddl = """
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT PRIMARY KEY,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
);
"""
conn = _get_conn()
try:
with conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
finally:
conn.close()
def _generate_csv(rows: int, csv_dir: Path) -> str:
"""Генерирует CSV c заказами с помощью pandas и сохраняет на диск."""
import pandas as pd
csv_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
csv_path = csv_dir / f"orders_{timestamp}.csv"
# Генерируем данные в pandas-стиле
base_order_id = int(datetime.now(UTC).timestamp() * 1_000)
# Создаём DataFrame с использованием pandas методов
df = pd.DataFrame(
{
# Уникальные order_id начиная с базового значения
"order_id": pd.Series(
range(base_order_id, base_order_id + rows), dtype="int64"
),
# Временные метки с интервалом в 1 секунду в обратном порядке
"order_ts": pd.date_range(
end=datetime.now(UTC), periods=rows, freq="1S"
).sort_values(ascending=False),
# Случайные customer_id от 1 до 1000
"customer_id": pd.Series(
random.choices(range(1, 1001), k=rows), dtype="int64"
),
# Случайные суммы от 10 до 500 с округлением до 2 знаков
"amount": pd.Series(
[round(random.uniform(10, 500), 2) for _ in range(rows)],
dtype="float64",
),
}
)
# Сохраняем CSV без индекса
df.to_csv(csv_path, index=False)
logging.info("CSV сохранён: %s (строк: %s)", csv_path, len(df))
return str(csv_path)
def _preview_csv(csv_path: str, sample_rows: int = 5) -> None:
"""Отображает предпросмотр CSV через pandas (head и describe)."""
import pandas as pd
df = pd.read_csv(csv_path)
df["order_ts"] = pd.to_datetime(df["order_ts"], errors="coerce")
logging.info(
"Первые %s строк:\n%s", sample_rows, df.head(sample_rows).to_string(index=False)
)
numeric_summary = df.describe(include="number")
logging.info("Числовая статистика:\n%s", numeric_summary.to_string())
if df["order_ts"].notna().any():
logging.info(
"Диапазон order_ts: %s → %s",
df["order_ts"].min().isoformat(),
df["order_ts"].max().isoformat(),
)
def _load_csv(csv_path: str) -> None:
"""Загружает CSV в Postgres через временную таблицу и anti-join."""
csv_file = Path(csv_path)
if not csv_file.exists():
raise FileNotFoundError(f"CSV не найден: {csv_file}")
conn = _get_conn()
try:
with conn.cursor() as cur, csv_file.open("r", encoding="utf-8") as f:
cur.execute(
"CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;"
)
cur.copy_expert(
"COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER",
f,
)
cur.execute("SELECT COUNT(*) FROM tmp_orders")
tmp_rows = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
SELECT t.order_id, t.order_ts, t.customer_id, t.amount
FROM tmp_orders t
LEFT JOIN public.orders o ON o.order_id = t.order_id
WHERE o.order_id IS NULL
"""
)
inserted = cur.rowcount if cur.rowcount != -1 else 0
conn.commit()
finally:
conn.close()
logging.info("Загружено строк: %s (прочитано из CSV: %s)", inserted, tmp_rows)
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="csv_to_postgres",
start_date=datetime(2023, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "postgres", "csv"],
) as dag:
create_table = PythonOperator(
task_id="create_orders_table",
python_callable=_create_table,
)
generate_csv = PythonOperator(
task_id="generate_csv",
python_callable=_generate_csv,
op_kwargs={"rows": CSV_ROWS, "csv_dir": CSV_DIR},
)
preview_csv = PythonOperator(
task_id="preview_csv",
python_callable=_preview_csv,
op_kwargs={
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
"sample_rows": 5,
},
)
load_csv = PythonOperator(
task_id="load_csv_to_postgres",
python_callable=_load_csv,
op_kwargs={
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
},
)
create_table >> generate_csv >> preview_csv >> load_csv