Python para Data Engineering
Python es el lenguaje de facto del Data Engineering moderno. Desde manipulaciΓ³n de datos con Pandas y Polars hasta la construcciΓ³n de pipelines completos, APIs y testing automatizado.
Python Esencial para Data Engineering
Antes de las librerΓas, el dominio de Python puro es fundamental. Un Data Engineer escribe cΓ³digo de producciΓ³n β limpio, testeable, mantenible y eficiente.
Patrones PythΓ³nicos Clave
"""
Python patterns esenciales para Data Engineering
Python 3.11+ features incluidos
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Generator, Iterator, Any
from pathlib import Path
from contextlib import contextmanager
from functools import lru_cache, partial
import itertools
import json
# ββ DATACLASSES: Estructuras de datos tipadas ββββββββββββββββββββββββββββββ
@dataclass
class DataPipelineConfig:
"""ConfiguraciΓ³n de pipeline con defaults y validaciΓ³n."""
source_url: str
batch_size: int = 1000
max_retries: int = 3
timeout_seconds: float = 30.0
output_path: Path = field(default_factory=lambda: Path("./output"))
tags: list[str] = field(default_factory=list)
def __post_init__(self):
# ValidaciΓ³n al crear
if self.batch_size <= 0:
raise ValueError(f"batch_size debe ser positivo, got {self.batch_size}")
if not self.source_url.startswith(("http://", "https://", "gs://", "s3://")):
raise ValueError(f"URL de fuente invΓ‘lida: {self.source_url}")
self.output_path.mkdir(parents=True, exist_ok=True)
# ββ GENERATORS: Procesamiento de datos en streaming sin cargar en memoria ββ
def read_csv_in_chunks(filepath: Path, chunk_size: int = 1000) -> Generator[list[dict], None, None]:
"""Genera chunks de CSV sin cargar el archivo completo en memoria."""
with open(filepath, 'r', encoding='utf-8') as f:
import csv
reader = csv.DictReader(f)
batch = []
for i, row in enumerate(reader):
batch.append(row)
if len(batch) >= chunk_size:
yield batch
batch = []
if batch: # Γltimo batch (puede ser menor que chunk_size)
yield batch
# Uso: procesa millones de filas con memoria constante
for chunk in read_csv_in_chunks(Path("huge_file.csv"), chunk_size=5000):
process_batch(chunk)
# ββ CONTEXT MANAGERS: GestiΓ³n automΓ‘tica de recursos ββββββββββββββββββββββ
@contextmanager
def timer(operation_name: str) -> Iterator[None]:
"""Context manager para medir tiempo de operaciones."""
import time
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"β±οΈ {operation_name}: {elapsed:.3f}s")
with timer("Data extraction"):
data = extract_from_api()
# ββ TYPE HINTS AVANZADOS (Python 3.10+) ββββββββββββββββββββββββββββββββββββ
from typing import TypeAlias, TypeVar
DataRow: TypeAlias = dict[str, Any]
T = TypeVar('T')
def transform_rows(
rows: list[DataRow],
*transformers: Callable[[DataRow], DataRow],
filter_fn: Callable[[DataRow], bool] | None = None
) -> list[DataRow]:
"""Aplica una cadena de transformaciones a filas de datos."""
result = rows
for transform in transformers:
result = [transform(row) for row in result]
if filter_fn:
result = [row for row in result if filter_fn(row)]
return result
# ββ MATCH STATEMENT (Python 3.10+) βββββββββββββββββββββββββββββββββββββββββ
def handle_api_response(response_data: dict) -> str:
"""Maneja diferentes tipos de respuesta de API."""
match response_data:
case {"status": "success", "data": list() as items}:
return f"β
{len(items)} registros recibidos"
case {"status": "error", "code": int(code), "message": str(msg)}:
return f"β Error {code}: {msg}"
case {"status": "partial", "data": items, "total": total}:
return f"β οΈ Parcial: {len(items)}/{total} registros"
case _:
return f"β Respuesta desconocida: {response_data}"
Manejo de Errores y Retry Logic
"""
Patrones de resiliencia para pipelines de producciΓ³n
"""
import time
import logging
from functools import wraps
from typing import TypeVar, Callable
from exceptions import PipelineError, TransientError
F = TypeVar('F', bound=Callable)
logger = logging.getLogger(__name__)
def retry_with_backoff(
max_attempts: int = 3,
initial_delay: float = 1.0,
backoff_factor: float = 2.0,
exceptions: tuple[type[Exception], ...] = (TransientError,)
):
"""Decorador que reintenta automΓ‘ticamente ante errores transientes."""
def decorator(func: F) -> F:
@wraps(func)
def wrapper(*args, **kwargs):
delay = initial_delay
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == max_attempts - 1:
logger.error(f"Max retries ({max_attempts}) exceeded: {e}")
raise
logger.warning(
f"Attempt {attempt + 1}/{max_attempts} failed: {e}. "
f"Retrying in {delay:.1f}s..."
)
time.sleep(delay)
delay *= backoff_factor
return wrapper
return decorator
@retry_with_backoff(max_attempts=5, initial_delay=2.0)
def fetch_data_from_api(endpoint: str) -> dict:
"""Fetch con retry automΓ‘tico."""
import requests
response = requests.get(endpoint, timeout=30)
response.raise_for_status()
return response.json()
Pandas: El EstΓ‘ndar de la Industria
Pandas sigue siendo la librerΓa mΓ‘s usada para manipulaciΓ³n de datos en Python. Dominar sus patrones avanzados es imprescindible.
"""
Pandas avanzado para Data Engineering
pandas 2.x con mejoras de performance
"""
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
# ββ LECTURA EFICIENTE βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Especificar dtypes al leer SIEMPRE (evita inferencia lenta)
dtypes = {
'order_id': 'int64',
'customer_id': 'int32',
'total_amount': 'float32', # float32 en lugar de float64 ahorra 50% memoria
'status': 'category', # category para strings repetitivos
}
df = pd.read_csv('orders.csv',
dtype=dtypes,
parse_dates=['order_date'],
usecols=['order_id', 'customer_id', 'total_amount', 'status', 'order_date'],
chunksize=None # O leer en chunks si el archivo es enorme
)
# Leer con chunks para archivos grandes
reader = pd.read_csv('huge_file.csv', chunksize=50_000)
processed_chunks = []
for chunk in reader:
clean_chunk = chunk.dropna(subset=['customer_id']).copy()
processed_chunks.append(clean_chunk)
df = pd.concat(processed_chunks, ignore_index=True)
# ββ TRANSFORMACIONES AVANZADAS βββββββββββββββββββββββββββββββββββββββββββββ
# .assign() para transformaciones encadenadas (mΓ‘s limpio que mΓΊltiples =)
df_transformed = (df
.assign(
# Transformaciones de fecha
year = lambda x: x['order_date'].dt.year,
month = lambda x: x['order_date'].dt.month,
day_of_week= lambda x: x['order_date'].dt.day_name(),
week_num = lambda x: x['order_date'].dt.isocalendar().week,
# Campos calculados
is_large_order = lambda x: x['total_amount'] > 500,
discount_amount= lambda x: np.where(x['status'] == 'vip',
x['total_amount'] * 0.1, 0),
# NormalizaciΓ³n
amount_normalized = lambda x: (x['total_amount'] - x['total_amount'].mean())
/ x['total_amount'].std()
)
.query("status != 'cancelled'")
.drop_duplicates(subset=['order_id'])
)
# ββ MERGE (JOIN) en Pandas βββββββββββββββββββββββββββββββββββββββββββββββββ
orders = pd.read_csv('orders.csv')
customers = pd.read_csv('customers.csv')
products = pd.read_csv('products.csv')
# JOIN mΓΊltiple encadenado
result = (orders
.merge(customers, on='customer_id', how='left', suffixes=('', '_cust'))
.merge(products, on='product_id', how='inner', suffixes=('', '_prod'))
)
# ββ GROUPBY AVANZADO ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# agg() con mΓΊltiples funciones por columna
customer_stats = df.groupby('customer_id').agg(
total_orders = ('order_id', 'count'),
total_revenue = ('total_amount', 'sum'),
avg_order = ('total_amount', 'mean'),
max_order = ('total_amount', 'max'),
first_purchase = ('order_date', 'min'),
last_purchase = ('order_date', 'max'),
unique_statuses= ('status', 'nunique'),
).reset_index()
# Agregar mΓ©tricas calculadas
customer_stats['lifespan_days'] = (
customer_stats['last_purchase'] - customer_stats['first_purchase']
).dt.days
customer_stats['avg_days_between_orders'] = (
customer_stats['lifespan_days'] / customer_stats['total_orders']
).round(1)
# ββ WINDOW FUNCTIONS equivalentes en Pandas ββββββββββββββββββββββββββββββββ
# transform: groupby pero mantiene el Γndice original (como WINDOW en SQL)
df['customer_rank'] = df.groupby('customer_id')['total_amount'].rank(
method='dense', ascending=False
)
df['customer_total_rev'] = df.groupby('customer_id')['total_amount'].transform('sum')
df['pct_of_customer_rev'] = df['total_amount'] / df['customer_total_rev']
# Cumulative sum por grupo
df_sorted = df.sort_values(['customer_id', 'order_date'])
df_sorted['cumulative_spent'] = df_sorted.groupby('customer_id')['total_amount'].cumsum()
# ββ OPTIMIZACIΓN DE MEMORIA βββββββββββββββββββββββββββββββββββββββββββββββββ
def optimize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
"""Reduce el uso de memoria del DataFrame automΓ‘ticamente."""
original_mem = df.memory_usage(deep=True).sum() / 1024**2
for col in df.columns:
col_type = df[col].dtype
if col_type == 'int64':
# Reducir int64 β int32 o int16 si es posible
if df[col].between(-32768, 32767).all():
df[col] = df[col].astype('int16')
elif df[col].between(-2147483648, 2147483647).all():
df[col] = df[col].astype('int32')
elif col_type == 'float64':
df[col] = df[col].astype('float32')
elif col_type == 'object':
# Si hay poca cardinalidad, usar category
if df[col].nunique() / len(df) < 0.5:
df[col] = df[col].astype('category')
new_mem = df.memory_usage(deep=True).sum() / 1024**2
print(f"Memoria: {original_mem:.1f}MB β {new_mem:.1f}MB ({(1-new_mem/original_mem)*100:.0f}% reducciΓ³n)")
return df
Polars: El Futuro del Procesamiento en Python
Polars es la alternativa moderna a Pandas. Escrito en Rust, usa paralelismo nativo y operaciones lazy para ser 5-100x mΓ‘s rΓ‘pido que Pandas en la mayorΓa de operaciones.
Polars aprovecha todos los cores del CPU automΓ‘ticamente, usa memoria eficientemente con Apache Arrow, tiene API lazy que optimiza queries automΓ‘ticamente (como un motor SQL), y su API es mΓ‘s consistente que Pandas. Para nuevos proyectos, Polars es la opciΓ³n recomendada.
"""
Polars para Data Engineering
polars 0.20+
"""
import polars as pl
from polars import col, lit, when
from datetime import date
# ββ LECTURA: Lazy por defecto ββββββββββββββββββββββββββββββββββββββββββββββ
# LazyFrame: define el plan sin ejecutar (como un query plan de SQL)
lf = pl.scan_csv("orders.csv",
dtypes={
"order_id": pl.Int64,
"amount": pl.Float32,
"status": pl.Categorical,
},
try_parse_dates=True
)
# La query se optimiza ANTES de ejecutar
result = (lf
.filter(col("status") != "cancelled")
.with_columns([
col("order_date").dt.year().alias("year"),
col("order_date").dt.month().alias("month"),
(col("amount") * 1.21).alias("amount_with_tax"),
when(col("amount") > 500).then(lit("large"))
.when(col("amount") > 100).then(lit("medium"))
.otherwise(lit("small")).alias("order_size"),
])
.group_by(["year", "month", "status"])
.agg([
pl.count("order_id").alias("order_count"),
pl.sum("amount").alias("total_revenue"),
pl.mean("amount").alias("avg_amount"),
pl.quantile("amount", 0.9).alias("p90_amount"),
])
.sort(["year", "month"])
.collect() # Ejecutar el plan optimizado
)
# ββ EXPRESIONES: mΓ‘s expresivas que Pandas βββββββββββββββββββββββββββββββββ
df = pl.read_csv("sales.csv")
# MΓΊltiples transformaciones de una vez (mΓ‘s rΓ‘pido que Pandas)
df_clean = df.with_columns([
# String operations
col("name").str.strip_chars().str.to_lowercase().alias("name_clean"),
col("email").str.to_lowercase().alias("email_lower"),
# Date operations
col("order_date").str.strptime(pl.Date, "%Y-%m-%d"),
# Conditional
when(col("amount") >= 0).then(col("amount")).otherwise(lit(0)).alias("amount_positive"),
# Arithmetic
(col("price") * col("quantity") * (1 - col("discount_pct") / 100)).alias("net_amount"),
])
# ββ GROUP BY: Mucho mΓ‘s rΓ‘pido que Pandas ββββββββββββββββββββββββββββββββββ
stats = df.group_by(["category", "year"]).agg([
pl.count().alias("transactions"),
pl.sum("amount").alias("revenue"),
pl.n_unique("customer_id").alias("unique_customers"),
pl.col("amount").mean().alias("avg_amount"),
pl.col("amount").std().alias("std_amount"),
pl.col("amount").quantile(0.5).alias("median"),
pl.col("amount").filter(col("status") == "returned").sum().alias("returned_amount"),
])
# ββ JOINS en Polars ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
orders = pl.read_csv("orders.csv")
customers = pl.read_csv("customers.csv")
joined = orders.join(customers, on="customer_id", how="left")
# ββ PANDAS vs POLARS: Comparativa de Performance ββββββββββββββββββββββββββββ
import time
# Pandas (1M rows)
import pandas as pd
df_pd = pd.DataFrame({"a": range(1_000_000), "b": [i % 100 for i in range(1_000_000)]})
t1 = time.perf_counter()
result_pd = df_pd.groupby("b")["a"].sum()
pandas_time = time.perf_counter() - t1
# Polars (mismo dataset)
df_pl = pl.DataFrame({"a": range(1_000_000), "b": [i % 100 for i in range(1_000_000)]})
t1 = time.perf_counter()
result_pl = df_pl.group_by("b").agg(pl.sum("a"))
polars_time = time.perf_counter() - t1
print(f"Pandas: {pandas_time:.3f}s")
print(f"Polars: {polars_time:.3f}s")
print(f"Speedup: {pandas_time/polars_time:.1f}x") # TΓpicamente 10-50x
| Feature | Pandas | Polars |
|---|---|---|
| Performance | Baseline | 5-100x mΓ‘s rΓ‘pido |
| Paralelismo | Single-threaded | Multi-threaded nativo |
| Memoria | Alta (copies) | Eficiente (Arrow, zero-copy) |
| API Lazy | No | SΓ (scan_*, query planning) |
| Streaming | Manual chunks | Native streaming mode |
| Ecosistema | Maduro, enorme | Creciendo rΓ‘pido |
| NaN handling | Confuso (NaN vs None) | Consistente (null) |
| RecomendaciΓ³n 2026 | Legacy/integraciΓ³n | Nuevos proyectos |
SQLAlchemy: Python β Bases de Datos
"""
SQLAlchemy 2.0+ para Data Engineering
ORM + Core + Connection pooling
"""
from sqlalchemy import create_engine, text, Column, Integer, String, Float, DateTime
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.dialects.postgresql import insert as pg_insert
from contextlib import contextmanager
import pandas as pd
# ββ ENGINE: Connection pooling automΓ‘tico βββββββββββββββββββββββββββββββββ
engine = create_engine(
"postgresql+psycopg2://user:password@host:5432/dbname",
pool_size=10, # Conexiones en el pool
max_overflow=20, # Conexiones adicionales mΓ‘ximas
pool_pre_ping=True, # Verifica conexiones antes de usarlas
pool_recycle=3600, # Recicla conexiones cada hora
echo=False, # True para debug (loguea SQL)
connect_args={
"connect_timeout": 10,
"application_name": "my_data_pipeline",
}
)
# ββ ORM: Definir modelos βββββββββββββββββββββββββββββββββββββββββββββββββββ
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "orders"
order_id = Column(Integer, primary_key=True)
customer_id = Column(Integer, nullable=False, index=True)
amount = Column(Float, nullable=False)
status = Column(String(20), default="pending", index=True)
created_at = Column(DateTime, nullable=False)
# Crear tablas
Base.metadata.create_all(engine)
# ββ SESSION: Transacciones βββββββββββββββββββββββββββββββββββββββββββββββββ
SessionLocal = sessionmaker(bind=engine)
@contextmanager
def get_session():
"""Context manager para sesiones con rollback automΓ‘tico."""
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
# Uso
with get_session() as session:
new_order = Order(customer_id=1, amount=299.99, status="pending")
session.add(new_order)
# Commit automΓ‘tico al salir del context manager
# ββ CORE: SQL expresivo ββββββββββββββββββββββββββββββββββββββββββββββββββββ
from sqlalchemy import select, func, and_, or_, update, delete
with engine.connect() as conn:
# SELECT con filtros
stmt = select(Order).where(
and_(Order.status == "pending", Order.amount > 100)
).order_by(Order.created_at.desc()).limit(100)
results = conn.execute(stmt).fetchall()
# SQL raw con parΓ‘metros (seguro contra SQL injection)
result = conn.execute(
text("SELECT customer_id, SUM(amount) AS total FROM orders WHERE status = :status GROUP BY customer_id"),
{"status": "delivered"}
)
# ββ PANDAS + SQLAlchemy: el dΓΊo perfecto ββββββββββββββββββββββββββββββββββ
# Leer query directamente en DataFrame
df = pd.read_sql_query(
sql="SELECT * FROM orders WHERE order_date >= :since",
con=engine,
params={"since": "2026-01-01"}
)
# Escribir DataFrame a la base de datos
df.to_sql(
name="fact_orders",
con=engine,
schema="analytics",
if_exists="append",
index=False,
method="multi", # Batch inserts
chunksize=1000
)
# ββ UPSERT: INSERT ON CONFLICT (PostgreSQL) ββββββββββββββββββββββββββββββββ
def upsert_dataframe(df: pd.DataFrame, table_name: str, primary_keys: list[str]) -> int:
"""Upsert (INSERT or UPDATE) de un DataFrame en PostgreSQL."""
rows = df.to_dict(orient='records')
if not rows:
return 0
with engine.begin() as conn:
stmt = pg_insert(table_name).values(rows)
update_columns = {
col: stmt.excluded[col]
for col in df.columns
if col not in primary_keys
}
stmt = stmt.on_conflict_do_update(
index_elements=primary_keys,
set_=update_columns
)
result = conn.execute(stmt)
return result.rowcount
Ingesta desde APIs REST
"""
Patrones para consumir APIs REST en Data Engineering
httpx (async), requests, paginaciΓ³n, rate limiting
"""
import httpx
import asyncio
import time
from typing import AsyncGenerator
from dataclasses import dataclass
@dataclass
class APIConfig:
base_url: str
api_key: str
rate_limit_rps: float = 10.0 # Requests por segundo
timeout: float = 30.0
class DataAPIClient:
def __init__(self, config: APIConfig):
self.config = config
self._last_request_time = 0.0
def _rate_limit(self):
"""Throttle para respetar rate limits."""
min_interval = 1.0 / self.config.rate_limit_rps
elapsed = time.monotonic() - self._last_request_time
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
self._last_request_time = time.monotonic()
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
def get(self, endpoint: str, params: dict = None) -> dict:
self._rate_limit()
with httpx.Client(
base_url=self.config.base_url,
headers={"Authorization": f"Bearer {self.config.api_key}"},
timeout=self.config.timeout
) as client:
response = client.get(endpoint, params=params)
response.raise_for_status()
return response.json()
def paginate(self, endpoint: str, page_size: int = 100) -> list[dict]:
"""Maneja paginaciΓ³n automΓ‘ticamente."""
all_data = []
page = 1
while True:
response = self.get(endpoint, params={"page": page, "per_page": page_size})
# Adaptar segΓΊn el formato de la API
if isinstance(response, list):
data = response
has_more = len(data) == page_size
elif isinstance(response, dict):
data = response.get("data", response.get("results", []))
has_more = response.get("has_more", response.get("next") is not None)
all_data.extend(data)
if not has_more or not data:
break
page += 1
print(f" PΓ‘gina {page}: {len(all_data)} registros total")
return all_data
# ββ ASYNC: MΓΊltiples APIs en paralelo βββββββββββββββββββββββββββββββββββββ
async def fetch_all_endpoints(
base_url: str,
endpoints: list[str],
api_key: str
) -> dict[str, list[dict]]:
"""Fetcha mΓΊltiples endpoints en paralelo (mucho mΓ‘s rΓ‘pido que secuencial)."""
async with httpx.AsyncClient(
base_url=base_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30.0
) as client:
async def fetch_one(endpoint: str) -> tuple[str, list]:
response = await client.get(endpoint)
response.raise_for_status()
return endpoint, response.json()
results = await asyncio.gather(
*[fetch_one(ep) for ep in endpoints],
return_exceptions=True
)
return {
endpoint: data
for endpoint, data in results
if not isinstance(data, Exception)
}
# Uso: fetcha 10 endpoints en paralelo
endpoints = ["/users", "/orders", "/products", "/categories", "/reviews"]
all_data = asyncio.run(fetch_all_endpoints(BASE_URL, endpoints, API_KEY))
# En lugar de 10 * 2s = 20s, tarda ~2s (el tiempo del endpoint mΓ‘s lento)
Logging Profesional para Pipelines
"""
Logging estructurado para pipelines de producciΓ³n
JSON logging para parseo automatizado por ELK/Grafana
"""
import logging
import json
import sys
from datetime import datetime, timezone
class StructuredFormatter(logging.Formatter):
"""Formatea logs como JSON para ingesta por sistemas de observabilidad."""
def format(self, record: logging.LogRecord) -> str:
log_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# Agregar contexto adicional si existe
if hasattr(record, 'pipeline_id'):
log_data['pipeline_id'] = record.pipeline_id
if hasattr(record, 'batch_id'):
log_data['batch_id'] = record.batch_id
if record.exc_info:
log_data['exception'] = self.formatException(record.exc_info)
return json.dumps(log_data)
def setup_logger(name: str, level: str = "INFO") -> logging.Logger:
"""Configura logger estructurado."""
logger = logging.getLogger(name)
logger.setLevel(getattr(logging, level.upper()))
# Console handler (JSON en producciΓ³n, legible en dev)
handler = logging.StreamHandler(sys.stdout)
if os.getenv("ENV", "dev") == "production":
handler.setFormatter(StructuredFormatter())
else:
handler.setFormatter(logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
))
logger.addHandler(handler)
return logger
# Uso en un pipeline
logger = setup_logger("pipeline.orders")
class OrdersPipeline:
def __init__(self):
self.pipeline_id = f"orders_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
def process_batch(self, batch_id: str, records: list) -> dict:
extra = {"pipeline_id": self.pipeline_id, "batch_id": batch_id}
logger.info(f"Processing batch: {len(records)} records", extra=extra)
try:
processed = [self.transform(r) for r in records]
failed = [r for r in records if not self.validate(r)]
logger.info(
f"Batch complete: {len(processed)} processed, {len(failed)} failed",
extra={**extra, "success_count": len(processed), "failed_count": len(failed)}
)
return {"processed": len(processed), "failed": len(failed)}
except Exception as e:
logger.error(f"Batch failed: {e}", exc_info=True, extra=extra)
raise
Testing de Pipelines de Datos
"""
Testing con pytest para Data Engineering
pytest + pytest-mock + Great Expectations
"""
import pytest
import pandas as pd
import numpy as np
from unittest.mock import Mock, patch, MagicMock
from pathlib import Path
# ββ UNIT TESTS: Funciones de transformaciΓ³n ββββββββββββββββββββββββββββββββ
def normalize_email(email: str) -> str:
return email.strip().lower() if email else None
def calculate_customer_ltv(orders_df: pd.DataFrame) -> pd.DataFrame:
return orders_df.groupby('customer_id').agg(
ltv=('amount', 'sum'),
orders=('order_id', 'count')
).reset_index()
class TestNormalizeEmail:
def test_lowercase_conversion(self):
assert normalize_email("ANA@EXAMPLE.COM") == "ana@example.com"
def test_strip_whitespace(self):
assert normalize_email(" ana@example.com ") == "ana@example.com"
def test_none_input(self):
assert normalize_email(None) is None
def test_empty_string(self):
assert normalize_email("") is None
class TestCalculateCustomerLTV:
@pytest.fixture
def sample_orders(self):
return pd.DataFrame({
'order_id': [1, 2, 3, 4, 5],
'customer_id': [101, 101, 102, 102, 103],
'amount': [100.0, 200.0, 50.0, 150.0, 300.0]
})
def test_correct_ltv_calculation(self, sample_orders):
result = calculate_customer_ltv(sample_orders)
customer_101 = result[result['customer_id'] == 101]
assert customer_101['ltv'].values[0] == pytest.approx(300.0)
assert customer_101['orders'].values[0] == 2
def test_all_customers_present(self, sample_orders):
result = calculate_customer_ltv(sample_orders)
assert len(result) == 3 # 3 customers ΓΊnicos
def test_empty_dataframe(self):
empty_df = pd.DataFrame(columns=['order_id', 'customer_id', 'amount'])
result = calculate_customer_ltv(empty_df)
assert len(result) == 0
# ββ INTEGRATION TESTS: Mock de external services βββββββββββββββββββββββββββ
class TestAPIIngestion:
@patch('httpx.Client')
def test_successful_api_fetch(self, mock_client_class):
# Arrange
mock_response = Mock()
mock_response.json.return_value = [{"id": 1, "name": "Test"}]
mock_response.status_code = 200
mock_client = MagicMock()
mock_client.__enter__.return_value.get.return_value = mock_response
mock_client_class.return_value = mock_client
# Act
client = DataAPIClient(APIConfig(base_url="http://api.test", api_key="key"))
result = client.get("/data")
# Assert
assert len(result) == 1
assert result[0]["name"] == "Test"
@patch('httpx.Client')
def test_retry_on_server_error(self, mock_client_class):
# Simulate 2 failures then success
import httpx
mock_get = Mock(side_effect=[
httpx.HTTPStatusError("500", request=Mock(), response=Mock(status_code=500)),
httpx.HTTPStatusError("503", request=Mock(), response=Mock(status_code=503)),
Mock(json=lambda: {"data": "ok"}, status_code=200)
])
mock_client = MagicMock()
mock_client.__enter__.return_value.get = mock_get
mock_client_class.return_value = mock_client
# Should succeed on 3rd attempt
result = client.get("/data")
assert mock_get.call_count == 3
# ββ DATA QUALITY TESTS: Validar schemas y constraints ββββββββββββββββββββββ
class TestDataQuality:
def test_no_null_customer_ids(self, sample_orders):
assert sample_orders['customer_id'].notna().all(), \
"customer_id no debe tener valores nulos"
def test_positive_amounts(self, sample_orders):
assert (sample_orders['amount'] >= 0).all(), \
"Los montos deben ser no-negativos"
def test_unique_order_ids(self, sample_orders):
assert sample_orders['order_id'].is_unique, \
"order_id debe ser ΓΊnico"
def test_schema_match(self, sample_orders):
expected_dtypes = {
'order_id': 'int64',
'customer_id': 'int64',
'amount': 'float64'
}
for col, expected_type in expected_dtypes.items():
assert str(sample_orders[col].dtype) == expected_type, \
f"Columna {col}: esperado {expected_type}, got {sample_orders[col].dtype}"
AutomatizaciΓ³n de Pipelines
"""
Pipeline modular y automatizado con patrones de producciΓ³n
"""
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
# ββ PATTERN: Pipeline con etapas intercambiables βββββββββββββββββββββββββββ
@runtime_checkable
class PipelineStage(Protocol):
name: str
def process(self, data: pd.DataFrame) -> pd.DataFrame: ...
class ExtractStage:
name = "extract"
def __init__(self, source: str):
self.source = source
def process(self, data: pd.DataFrame = None) -> pd.DataFrame:
return pd.read_csv(self.source)
class CleanStage:
name = "clean"
def process(self, data: pd.DataFrame) -> pd.DataFrame:
return (data
.dropna(subset=['customer_id', 'amount'])
.drop_duplicates()
.assign(amount=lambda x: x['amount'].clip(lower=0))
)
class Pipeline:
def __init__(self, stages: list[PipelineStage]):
self.stages = stages
self.logger = setup_logger(f"pipeline.{id(self)}")
def run(self) -> pd.DataFrame:
data = None
for stage in self.stages:
self.logger.info(f"Running stage: {stage.name}")
with timer(stage.name):
data = stage.process(data)
self.logger.info(f"Stage {stage.name} complete: {len(data)} rows")
return data
# Uso: composiciΓ³n flexible de stages
pipeline = Pipeline([
ExtractStage("s3://bucket/orders.csv"),
CleanStage(),
TransformStage(),
LoadStage(engine=engine, table="fact_orders")
])
result = pipeline.run()