Ревью Python-кода: типизация и идиомы
Проводит ревью Python-кода: ошибки, которые проявятся в рантайме, аннотации типов, идиоматичность, обработка исключений и ресурсов — с правками «до/после».
Пример запуска
Сгенерировано Claude Opus 5.5, 20 октября 2026 г.
Значения переменных
- Версия и библиотеки
- Python 3.11, FastAPI 0.110, httpx 0.27, pydantic 2.6, asyncio (stdlib)
- Назначение кода
- Сервис для интернет-магазина: по расписанию (раз в 10 минут) забирает список новых заказов у внешнего API поставщика, применяет скидки по промокодам из таблицы акций, формирует отчёт и сохраняет его в CSV-файл, а также шлёт уведомление в Telegram-бот менеджерам при ошибке получения заказов.
- Код
- import asyncio import csv import httpx import logging import os logging.basicConfig(level=logging.INFO) log = logging.getLogger("orders") PROMO_CACHE = {} # код акции -> процент скидки, заполняется при старте def load_promocodes(path, extra_codes=[]): with open(path) as f: for line in f: code, pct = line.strip().split(",") extra_codes.append(code) PROMO_CACHE[code] = float(pct) return extra_codes def apply_discount(order, promo=None): if promo == None: promo = order["promo_code"] pct = PROMO_CACHE[promo] total = order["price"] * order["qty"] discounted = total - total * pct / 100 order["final_price"] = discounted return order async def fetch_orders(client, since_id): resp = httpx.get(f"https://supplier.example.com/api/orders?since={since_id}") resp.raise_for_status() return resp.json() async def notify_manager(text): f = open("notify.log", "a") f.write(text + "\n") f.close() async def process_batch(since_id): try: async with httpx.AsyncClient(timeout=10) as client: orders = await fetch_orders(client, since_id) except Exception: try: raise RuntimeError("Не удалось получить заказы от поставщика") except RuntimeError as e: raise e asyncio.create_task(notify_manager(f"Обработано заказов: {len(orders)}")) result = [] for i in range(len(orders)): o = orders[i] try: processed = apply_discount(o) result.append(processed) except: pass report_path = "reports/" + str(since_id) + "/report.csv" os.makedirs(os.path.dirname(report_path), exist_ok=True) out = open(report_path, "w", newline="") writer = csv.writer(out) writer.writerow(["order_id", "final_price"]) for o in result: writer.writerow([o["id"], o["final_price"]]) out.close() return result async def main_loop(): since_id = 0 while True: batch = await process_batch(since_id) if batch: since_id = batch[-1]["id"] await asyncio.sleep(600) if __name__ == "__main__": load_promocodes("promocodes.csv") asyncio.run(main_loop())
Резюме
Код почти не работает в продакшене. Синхронный httpx.get блокирует event loop. Первый же сбой поставщика завершает main_loop, и менеджер об этом не узнаёт: при ошибке уведомление не отправляется, а при успехе пишется в локальный файл, а не в Telegram. Главный бизнес-риск: except: pass молча выкидывает из отчёта заказы без промокода, а since_id считается по отфильтрованному списку, поэтому заказы теряются или обрабатываются повторно.
Замечания
Критично
1. fetch_orders: блокирующий вызов в корутине, client не используется
httpx.get синхронный. Пока он ждёт ответа, весь event loop стоит, а timeout=10 и настройки клиента не применяются.
# до
resp = httpx.get(f"https://supplier.example.com/api/orders?since={since_id}")
# после
resp = await client.get(SUPPLIER_URL, params={"since": since_id})
2. process_batch / main_loop: ошибка поставщика убивает сервис, уведомления нет
- Исключение пробрасывается в
while Trueбез обработки, и после первого таймаута цикл завершается навсегда. - Вложенный
try/raise/except/raise eничего не делает. Нуженraise ... from e, иначе причина видна только через неявный__context__. - По ТЗ уведомление отправляется при ошибке. В коде оно отправляется при успехе, и в файл.
# до
except Exception:
try:
raise RuntimeError("Не удалось получить заказы от поставщика")
except RuntimeError as e:
raise e
# после
except (httpx.HTTPError, ValidationError) as e:
await notify_manager(client, cfg, f"Ошибка получения заказов: {type(e).__name__}")
raise SupplierError("Не удалось получить заказы от поставщика") from e
В main_loop исключение нужно ловить, логировать и продолжать цикл.
3. Голый except: pass в цикле заказов
- Заказ без
promo_codeили с неизвестным кодом вызываетKeyErrorи молча пропадает из отчёта. - Голый
exceptловит иasyncio.CancelledError, то есть мешает корректной отмене задачи.
# до
try:
processed = apply_discount(o)
result.append(processed)
except:
pass
# после: отсутствие промокода — штатная ветка, а не исключение
processed = [apply_discount(o, promos) for o in orders]
4. main_loop: since_id берётся из отфильтрованного result
Если последние заказы батча упали в except, since_id не сдвигается, и они обрабатываются снова. Если упали все, батч повторяется бесконечно.
# до
since_id = batch[-1]["id"]
# после: считаем по исходным заказам
return max(o.id for o in orders)
5. asyncio.create_task(notify_manager(...)) без ссылки
Event loop держит на задачу только слабую ссылку, поэтому её может собрать GC до завершения. Исключение из неё тоже теряется. Внутри при этом блокирующий open без with. Уведомление нужно просто дождаться через await, а если оно действительно фоновое, хранить ссылку в set и снимать её через add_done_callback.
Важно
6. float для денег. total * pct / 100 во float даёт значения вроде 899.9999999. Нужен Decimal и quantize(Decimal("0.01")).
7. apply_discount мутирует входной order. Лучше возвращать новый объект:
# до
order["final_price"] = discounted
return order
# после
return ProcessedOrder(id=order.id, final_price=total.quantize(CENT))
8. load_promocodes(path, extra_codes=[]): изменяемый дефолт. Список общий для всех вызовов и копится между ними. Параметр к тому же не нужен: функция должна возвращать словарь, а не заполнять глобальный объект.
9. Файлы без контекстного менеджера (out, f в notify_manager). При исключении в writerow дескриптор утекает, а CSV остаётся недописанным. Синхронная запись внутри корутины блокирует loop, её стоит вынести в asyncio.to_thread.
10. load_promocodes падает на заголовке и пустой строке (ValueError при распаковке). Нет encoding. Разбор через split(",") вместо csv.reader ломается на кавычках.
11. Типизация отсутствует полностью.
- Ответ API — непроверенный
Any. Pydantic уже в зависимостях, поэтому стоит описатьOrder(BaseModel)и валидировать ответ черезTypeAdapter(list[Order]). promo_code: str | Noneнужно проверять явно, а не черезKeyError.PROMO_CACHEлучше заменить наMapping[str, Decimal], передаваемый параметром.- Сигнатуры:
fetch_orders(client: httpx.AsyncClient, since_id: int) -> list[Order],process_batch(...) -> int.
12. promo == None → promo is None.
Желательно
for i in range(len(orders)): o = orders[i]заменить на списковое включение.writerowsвместо циклаwriterow.- Пути:
"reports/" + str(since_id) + "/report.csv"заменить наREPORTS_DIR / str(since_id) / "report.csv", аos.makedirsнаpath.parent.mkdir(parents=True, exist_ok=True). - Query-параметры передавать через
params=, а не f-строкой в URL. logging.basicConfigвыполняется при импорте модуля, это побочный эффект. Его место вmain().logобъявлен, но не используется. FastAPI в зависимостях есть, но в коде не задействован.- Один
AsyncClientстоит создавать на всё время жизни сервиса, а не на каждый батч: так переиспользуется пул соединений.
Итоговая версия
Самый проблемный фрагмент — получение заказов, обработка и основной цикл:
import asyncio
import csv
import logging
import os
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from pathlib import Path
import httpx
from pydantic import BaseModel, TypeAdapter, ValidationError
log = logging.getLogger("orders")
SUPPLIER_URL = "https://supplier.example.com/api/orders"
REPORTS_DIR = Path("reports")
POLL_INTERVAL_S = 600
CENT = Decimal("0.01")
@dataclass(frozen=True, slots=True)
class Config:
tg_token: str
tg_chat_id: str
class Order(BaseModel):
id: int
price: Decimal
qty: int
promo_code: str | None = None
class ProcessedOrder(BaseModel):
id: int
final_price: Decimal
class SupplierError(RuntimeError):
pass
_orders_adapter = TypeAdapter(list[Order])
def load_promocodes(path: Path) -> dict[str, Decimal]:
promos: dict[str, Decimal] = {}
with path.open(encoding="utf-8", newline="") as f:
for row in csv.reader(f):
if not row:
continue
code, pct = row
promos[code.strip()] = Decimal(pct.strip()) # мусор/заголовок -> падаем на старте
return promos
async def fetch_orders(client: httpx.AsyncClient, since_id: int) -> list[Order]:
resp = await client.get(SUPPLIER_URL, params={"since": since_id})
resp.raise_for_status()
return _orders_adapter.validate_json(resp.content)
def apply_discount(order: Order, promos: Mapping[str, Decimal]) -> ProcessedOrder:
total = order.price * order.qty
if order.promo_code is not None:
pct = promos.get(order.promo_code)
if pct is None:
log.warning("Неизвестный промокод %r в заказе %s", order.promo_code, order.id)
else:
total -= total * pct / 100
return ProcessedOrder(id=order.id, final_price=total.quantize(CENT, ROUND_HALF_UP))
def write_report(path: Path, rows: Iterable[ProcessedOrder]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(["order_id", "final_price"])
writer.writerows((r.id, r.final_price) for r in rows)
async def notify_manager(client: httpx.AsyncClient, cfg: Config, text: str) -> None:
"""Сбой уведомления не должен маскировать исходную ошибку."""
try:
resp = await client.post(
f"https://api.telegram.org/bot{cfg.tg_token}/sendMessage",
json={"chat_id": cfg.tg_chat_id, "text": text},
)
resp.raise_for_status()
except httpx.HTTPError as e:
# без exc_info: текст исключения содержит URL с токеном
log.error("Не удалось отправить уведомление в Telegram: %s", type(e).__name__)
async def process_batch(
client: httpx.AsyncClient,
cfg: Config,
promos: Mapping[str, Decimal],
since_id: int,
) -> int:
"""Обрабатывает батч и возвращает новый since_id."""
try:
orders = await fetch_orders(client, since_id)
except (httpx.HTTPError, ValidationError) as e:
await notify_manager(client, cfg, f"Ошибка получения заказов (since={since_id}): {type(e).__name__}")
raise SupplierError("Не удалось получить заказы от поставщика") from e
if not orders:
return since_id
processed = [apply_discount(o, promos) for o in orders]
await asyncio.to_thread(write_report, REPORTS_DIR / str(since_id) / "report.csv", processed)
log.info("Обработано заказов: %d", len(processed))
return max(o.id for o in orders)
async def main_loop(cfg: Config, promos: Mapping[str, Decimal]) -> None:
since_id = 0
async with httpx.AsyncClient(timeout=10) as client:
while True:
try:
since_id = await process_batch(client, cfg, promos, since_id)
except SupplierError:
log.exception("Батч since=%s пропущен", since_id)
except Exception: # супервизор демона: падение одного батча не останавливает сервис
log.exception("Непредвиденная ошибка в батче since=%s", since_id)
await asyncio.sleep(POLL_INTERVAL_S)
def main() -> None:
logging.basicConfig(level=logging.INFO)
cfg = Config(tg_token=os.environ["TG_BOT_TOKEN"], tg_chat_id=os.environ["TG_CHAT_ID"])
promos = load_promocodes(Path("promocodes.csv"))
asyncio.run(main_loop(cfg, promos))
if __name__ == "__main__":
main()
Неизвестный промокод здесь означает полную цену и предупреждение в логе. Это бизнес-решение, его нужно согласовать: вариант с отправкой заказа на ручную проверку тоже разумен, но заказ в любом случае не должен молча пропадать.
Похожие промпты
Ревью SQL-запросов и миграций
Проверяет SQL-запросы и миграции схемы: корректность на граничных данных, индексы под реальные выборки, блокировки при миграции на живой таблице и откат.
Ревью pull request по diff
Проводит ревью изменений по diff и описанию PR: соответствует ли код заявленной цели, что сломается рядом, чего не хватает в тестах, — и пишет комментарии в формате для GitHub или GitLab.
Ревью Next.js-кода: App Router и Server Components
Проводит ревью Next.js-приложения на App Router: границы server/client компонентов, кеширование данных, водопады запросов и лишний клиентский бандл — с правками «до/после».
SEO-аудит Next.js-приложения
Проверяет Next.js-приложение по SEO-чеклисту: метаданные и canonical, рендеринг для ботов, sitemap/robots, Core Web Vitals — и выдаёт план правок по приоритету.