97 lines
4.9 KiB
Markdown
97 lines
4.9 KiB
Markdown
---
|
||
paths: backend/**/*.py
|
||
---
|
||
|
||
# Backend conventions — Python 3.12 / FastAPI
|
||
|
||
## psycopg v3 (CRITICAL — recurring bug class)
|
||
|
||
- `import psycopg2` → **ModuleNotFoundError** — его нет в зависимостях. Только `psycopg[binary]>=3.2.0`.
|
||
- Bulk INSERT: `cur.executemany()` или COPY — НЕ `execute_values` (это psycopg2 API).
|
||
- В SQL `text(...)`: всегда `CAST(:name AS type)` — НИКОГДА `:name::type`.
|
||
- SQLAlchemy 2.0 + psycopg3 **игнорирует** `::type` после `:name` — `psycopg.errors.SyntaxError: syntax error at or near ':'`
|
||
- Исключение: `ARRAY[:rc]::int[]` РАБОТАЕТ — `::` приклеено к `)`, не к bind-name
|
||
- Pre-PR check: `grep -nE ':[a-z_]+::[a-z]' backend/app` → должен быть пуст
|
||
- Reference: vault `Pattern_CAST_AS_Type`, `Bug_SQLAlchemy_DoubleColon_Cast`
|
||
|
||
## SAVEPOINT pattern (loop upserts)
|
||
|
||
В цикле INSERT/UPSERT — **обязательно** `with db.begin_nested():` per-row:
|
||
|
||
```python
|
||
for row in items:
|
||
try:
|
||
with db.begin_nested():
|
||
db.execute(text("INSERT INTO ..."), {...})
|
||
except Exception as e:
|
||
logger.warning("row failed: %s", e)
|
||
```
|
||
|
||
❌ Bare `db.rollback()` в loop — откатывает **всю outer tx**, счётчики `inserted`/`updated` продолжают расти → inconsistent state.
|
||
|
||
Reference: vault `Bug_Pzz_Loader_Missing_Savepoint_May14`, `domrf_kn.py:427/452/472`.
|
||
|
||
## SQL injection prevention
|
||
|
||
❌ **Никогда**:
|
||
- f-string в SQL: `text(f"INSERT ... VALUES ({values})")`
|
||
- Home-rolled quote escape: `c.replace(chr(39), chr(39)*2)`
|
||
- String concat: `"... WHERE id = " + str(id)`
|
||
|
||
✅ **Canonical**: parametrized batch через SQLAlchemy `text` + list of dicts:
|
||
|
||
```python
|
||
db.execute(
|
||
text("INSERT INTO targets (job_id, cad_num) VALUES (:job_id, :cad_num) "
|
||
"ON CONFLICT (job_id, cad_num) DO NOTHING"),
|
||
[{"job_id": j, "cad_num": c} for c in cad_nums],
|
||
)
|
||
```
|
||
|
||
Reference: vault `Bug_Nspd_Geo_Sql_Injection_May14`.
|
||
|
||
## Ruff / code style
|
||
|
||
- **Line length 100** (`backend/pyproject.toml`)
|
||
- Типичные ловушки E501:
|
||
- SQL-литералы в `text("""...""")` — переноси после `FROM` / `WHERE` / `AND`
|
||
- `logger.info("...", a, b, c, d)` — разбивай на 2 строки
|
||
- Длинная сигнатура — аргументы по одному
|
||
- Ruff rules: `E F I B UP N RUF ASYNC` (RUF001/002/003 игнор — кириллица OK)
|
||
- mypy `strict`: `app.services.generative.*` + `app.services.site_finder.scorer`
|
||
- target-version `py312` — `dict | None`, `list[int]`, walrus OK
|
||
|
||
## Async / Celery
|
||
|
||
- FastAPI handlers: `async def`
|
||
- Celery tasks: `def` (Celery sync) — НЕ `async def` внутри task
|
||
- Sync↔async bridge через `asyncio.run()` внутри Celery task
|
||
- Tests: pytest + `asyncio_mode = auto`
|
||
|
||
## Запреты
|
||
|
||
- ❌ `print()` — только `logger.info/warning/error`
|
||
- ❌ `import requests` — только `httpx`
|
||
- ❌ `except Exception: pass` без re-raise или `logger.exception`
|
||
- ❌ Hardcode credentials — `os.environ.get("KEY")` или `settings.KEY`
|
||
- ❌ `Any` без комментария почему
|
||
|
||
## Web probing (scrapers — use playwright MCP, not curl)
|
||
|
||
При исследовании веба (как страница реагирует на параметры/фильтры, что меняется в DOM, какие селекторы возвращают данные, anti-bot behavior) — **сначала** `mcp__playwright__*`, а не curl/curl_cffi.
|
||
|
||
**Why:** Реальный браузер видит финальный URL после canonicalization + JS state без anti-bot ловушек. Curl попадает в captcha на 2-3-й итерации. User rule (2026-05-23): «если что-то не понятно лучше через плэйврайт смотри чем через консоль».
|
||
|
||
**How to apply:**
|
||
- Discover URL params / filter behavior → `browser_navigate` + `browser_snapshot`
|
||
- Verify selectors / count cards → `browser_evaluate` с JS возвращающим counts
|
||
- Inspect network → `browser_network_requests`
|
||
- Captcha detection → `browser_snapshot` + check для challenge elements
|
||
|
||
**Curl/curl_cffi оставляем** для actual scraping в production-скриптах **после** того как param set уже известен (playwright медленнее + heavier для bulk fetch).
|
||
|
||
Reference incident: 2026-05-23 reverse-engineering Yandex SERP filter params через curl — captcha на 2-й итерации, recovery через cookie-warm session. С playwright сразу видели бы JS state.
|
||
|
||
## Worker crash checklist
|
||
|
||
Worker падает на старте → скорее всего import error: `pyproject.toml` имеет dep но `uv.lock` не обновлён → `cd backend && uv lock` → commit lock → push.
|