Собесов

Aviasales Junior — рефакторинг исследовательского скрипта для регулярного запуска

PythonЧистый код и регулярные джобыСредняяJunior

Условие

Есть кусок исследовательского скрипта (см. ниже), который мы хотим поставить на регулярное выполнение. Перепишите код, отформатировав его для удобства дальнейшего переиспользования и исправив ошибки, если они есть. Напишите саммари того, что и почему изменили.

Исходный код (упрощённо):

import pandas as pd, numpy as np
from datetime import datetime, timedelta, date
from dateutil.relativedelta import relativedelta
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
 
today_dt = (datetime.today()).date()
today = today_dt.strftime('%Y-%m-%d')
yesterday_dt = today_dt - timedelta(days=1)
yesterday = yesterday_dt.strftime('%Y-%m-%d')
two_weeks_ago_start = (today_dt - timedelta(days=14)).strftime('%Y-%m-%d')
last_year_day = (yesterday_dt - relativedelta(years=1)).strftime('%Y-%m-%d')
 
total_aviasales_bookings = session.query(f"""
    select to_date(booked_at) as period, count(booking_id) as total_bookings_aviasales
    from bd.bookings
    where to_date(booked_at) >= '{two_weeks_ago_start}' and to_date(booked_at) <= '{yesterday}'
    group by 1 order by 1
""")
 
total_aviasales_gross_profit = session.query(f"""
    select to_date(booked_at) as period, sum(profit) as total_profit_aviasales
    from bd.bookings
    where to_date(booked_at) >= '{two_weeks_ago_start}' and to_date(booked_at) <= '{yesterday}'
    group by 1 order by 1
""")
 
# ... ещё две похожие выгрузки + 4 одинаковых по структуре графика

Решение

Подход

Скрипт — типичный «hand-written research notebook»: 4 почти одинаковых SQL-запроса, 4 однотипных графика, имена переменных непоследовательные, есть и реальные баги.

Для регулярного запуска нужно:

  1. Параметризовать даты (а не считать «сегодня - 14»).
  2. Вынести SQL в шаблоны/функции, не дублировать.
  3. Конфиг для путей/таблиц/цветов.
  4. Логирование вместо print.
  5. Обработка ошибок (что делать, если данных нет).
  6. Идемпотентность: запуск дважды не должен ломаться.
  7. Фикс багов в исходнике.

Найденные баги в исходнике

  1. Нерабочие переменные: в графике есть df_bookings_mego и df_bookings_mego_last_year, которых нет в коде — упадёт NameError (видимо, в исходнике переименовали часть переменных, но не все).
  2. Несовпадение полей: в SQL — booked_at и period, а в графике обращаемся к 'pdate' — не та колонка → KeyError.
  3. warnings.filterwarnings("ignore") глушит реальные предупреждения. Опасно в проде.
  4. Подстановка дат через f-string — потенциальный SQL-injection и проблема с экранированием. Используйте параметризацию.
  5. data_copy[...] * 2 + sqrt(...) — это не «forecast», это магическая формула без обоснования. Скорее всего, осталась с экспериментов.
  6. Нет timezone: datetime.today() возвращает локальное время. На сервере с другим tz расчёт «вчера» поедет.

Реализация — рефакторинг

"""
aviasales_daily_dashboard.py
 
Регулярный скрипт сборки графиков:
- бронирования и прибыль за последние 2 недели
- доля заказов с sender_id ∈ (10,20,30) — 2 недели и год
"""
 
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from pathlib import Path
 
import pandas as pd
import matplotlib.pyplot as plt
from dateutil.relativedelta import relativedelta
 
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
 
 
# --- 1. Конфиг -----------------------------------------------------------
@dataclass(frozen=True)
class Config:
    senders_of_interest: tuple = (10, 20, 30)
    short_window_days:    int  = 14
    long_window_years:    int  = 1
    output_dir:          Path = Path("artifacts")
    last_marker_color:    str  = "red"
 
 
# --- 2. Даты и окна ------------------------------------------------------
def windows(today: date, cfg: Config):
    yesterday = today - timedelta(days=1)
    short_start = today - timedelta(days=cfg.short_window_days)
    long_start = yesterday - relativedelta(years=cfg.long_window_years)
    return {
        "yesterday": yesterday,
        "short_start": short_start,
        "long_start": long_start,
    }
 
 
# --- 3. Параметризованные SQL-выгрузки ----------------------------------
SQL_BOOKINGS = """
    SELECT to_date(booked_at) AS period,
           COUNT(booking_id)  AS bookings,
           SUM(profit)        AS profit,
           COUNT(IF(sender_id IN :senders, booking_id, NULL)) AS bookings_sender
    FROM bd.bookings
    WHERE to_date(booked_at) BETWEEN :start AND :end
    GROUP BY 1 ORDER BY 1
"""
 
 
def fetch_period(session, start: date, end: date, senders: tuple) -> pd.DataFrame:
    df = session.query(SQL_BOOKINGS, params={
        "senders": senders, "start": start, "end": end
    })
    df["period"] = pd.to_datetime(df["period"])
    df["share_of_sender"] = df["bookings_sender"] / df["bookings"] * 100
    return df
 
 
# --- 4. Визуализация -----------------------------------------------------
def plot_panel(short: pd.DataFrame, long: pd.DataFrame, cfg: Config, out: Path):
    fig, axs = plt.subplots(2, 2, figsize=(12, 8))
    fig.suptitle("Daily metrics — Aviasales", fontsize=16)
 
    panels = [
        (axs[0, 0], short, "bookings",        "Bookings (2 weeks)"),
        (axs[0, 1], short, "profit",          "Profit (2 weeks)"),
        (axs[1, 0], short, "share_of_sender", "Sender share, % (2 weeks)"),
        (axs[1, 1], long,  "share_of_sender", "Sender share, % (year)"),
    ]
    for ax, df, col, title in panels:
        if df.empty:
            log.warning("No data for %s", title)
            ax.set_title(f"{title} — no data"); continue
        ax.plot(df["period"], df[col], marker="o")
        ax.scatter(df["period"].iloc[-1], df[col].iloc[-1],
                   color=cfg.last_marker_color, s=80, label="last day")
        ax.set_title(title); ax.set_xlabel("date"); ax.legend()
    fig.autofmt_xdate()
    out.mkdir(parents=True, exist_ok=True)
    path = out / f"daily_{date.today().isoformat()}.png"
    fig.savefig(path, dpi=120, bbox_inches="tight")
    log.info("Saved %s", path)
 
 
def main(session, today: date | None = None, cfg: Config = Config()):
    today = today or date.today()
    w = windows(today, cfg)
    log.info("Windows: %s", w)
    short = fetch_period(session, w["short_start"], w["yesterday"], cfg.senders_of_interest)
    long_ = fetch_period(session, w["long_start"],  w["yesterday"], cfg.senders_of_interest)
    plot_panel(short, long_, cfg, cfg.output_dir)
 
 
if __name__ == "__main__":
    from analytics_db import session  # placeholder: ваш коннектор
    main(session)

Саммари изменений

Что Зачем
Один SQL-шаблон вместо 4 DRY: исправление колонки в одном месте
params={...} вместо f-string защита от инъекций, корректные типы дат
dataclass Config один источник правды для констант
Логирование вместо print в проде нужны структурированные логи
today: date | None параметр возможность ручного бэкфилла
output_dir.mkdir(parents=True, exist_ok=True) идемпотентность
Удалена «формула forecast» была магической, без обоснования
if df.empty: warning мягкая деградация при отсутствии данных
Удалён warnings.filterwarnings("ignore") в проде нужно видеть реальные предупреждения
Имя файла-картинки с датой не перезаписываем вчерашнюю

Подводные камни

  1. Параметризация дат. Использование f"{date}" в SQL — это и SQL-injection, и проблема с типами. Параметризуйте через драйвер.
  2. datetime.today() без tz. Локальное время. На сервере в UTC — расчёт «вчера» сместится. Используйте date.today() с явным tz через pendulum/zoneinfo.
  3. Дублирование SQL. Если в 4 запросах разные WHERE — не пихайте всё в один; используйте функции с параметрами.
  4. Графики молча падают на пустых данных. В рефакторе — guard if df.empty.
  5. «Магические числа» (10, 20, 30 в sender_id). Вынесли в конфиг.
  6. Имя файла без даты. Перезапись «вчерашнего» отчёта при ручном повторе. Добавили today.isoformat().

Эталонный ответ

Главное в ответе — назвать конкретные баги (несуществующие переменные df_bookings_mego, разные имена колонок period vs pdate, magic-формула forecast, f-string SQL без параметров) и объяснить философию рефакторинга (DRY, конфиг, логи, идемпотентность). Не просто «причесать», а сделать код пригодным для cron / Airflow.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти