diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2378968..dced61f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,10 @@ jobs: working-directory: backend run: uv sync + - name: Lint (ruff) + working-directory: backend + run: uv run ruff check && uv run ruff format --check + - name: Run tests working-directory: backend env: @@ -41,6 +45,10 @@ jobs: working-directory: bot run: uv sync + - name: Lint (ruff) + working-directory: bot + run: uv run ruff check && uv run ruff format --check + - name: Run tests working-directory: bot env: diff --git a/backend/app/auth.py b/backend/app/auth.py index 1c37898..0b68566 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import jwt from fastapi import Depends, HTTPException, status @@ -10,16 +10,24 @@ def create_token() -> str: - expire = datetime.now(timezone.utc) + timedelta(hours=settings.token_expire_hours) - return jwt.encode({"sub": "admin", "exp": expire}, settings.secret_key, algorithm="HS256") + expire = datetime.now(UTC) + timedelta(hours=settings.token_expire_hours) + return jwt.encode( + {"sub": "admin", "exp": expire}, settings.secret_key, algorithm="HS256" + ) def verify_token(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)): try: - payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=["HS256"]) + payload = jwt.decode( + credentials.credentials, settings.secret_key, algorithms=["HS256"] + ) if payload.get("sub") != "admin": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) except jwt.ExpiredSignatureError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired" + ) from None except jwt.InvalidTokenError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) from None diff --git a/backend/app/config.py b/backend/app/config.py index 95ea3bd..c7ebeb2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,4 +13,5 @@ class Settings(BaseSettings): # env_file = "../.env" # extra = "ignore" + settings = Settings() diff --git a/backend/app/models.py b/backend/app/models.py index 8503fb2..e12373e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,5 @@ import json -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlalchemy import Column, DateTime, Integer, String, Text from sqlalchemy.orm import validates @@ -18,9 +18,10 @@ class Card(Base): # хранит список тегов как JSON строку: ["list", "dict"] tags = Column(Text, default="[]") difficulty = Column(String(10), nullable=False, default="normal") - created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) - updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), - onupdate=lambda: datetime.now(timezone.utc)) + created_at = Column(DateTime, default=lambda: datetime.now(UTC)) + updated_at = Column( + DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC) + ) @validates("difficulty") def validate_difficulty(self, key, value): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 881228f..dc9517c 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -8,7 +8,13 @@ from app.config import settings from app.database import get_db from app.models import Card -from app.schemas import CardCreate, CardResponse, CardUpdate, LoginRequest, TokenResponse +from app.schemas import ( + CardCreate, + CardResponse, + CardUpdate, + LoginRequest, + TokenResponse, +) router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -21,11 +27,15 @@ def login(body: LoginRequest): body.password.encode(), settings.admin_password.encode() ) if not password_ok: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password" + ) return TokenResponse(access_token=create_token()) -@router.post("/cards", response_model=CardResponse, dependencies=[Depends(verify_token)]) +@router.post( + "/cards", response_model=CardResponse, dependencies=[Depends(verify_token)] +) def create_card(body: CardCreate, db: Session = Depends(get_db)): card = Card( question=body.question, @@ -41,7 +51,11 @@ def create_card(body: CardCreate, db: Session = Depends(get_db)): return card -@router.put("/cards/{card_id}", response_model=CardResponse, dependencies=[Depends(verify_token)]) +@router.put( + "/cards/{card_id}", + response_model=CardResponse, + dependencies=[Depends(verify_token)], +) def update_card(card_id: int, body: CardUpdate, db: Session = Depends(get_db)): card = db.query(Card).filter(Card.id == card_id).first() if not card: diff --git a/backend/app/routers/cards.py b/backend/app/routers/cards.py index 2882c74..dce215a 100644 --- a/backend/app/routers/cards.py +++ b/backend/app/routers/cards.py @@ -1,5 +1,5 @@ import json -from typing import Literal, Optional +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import or_ @@ -14,10 +14,10 @@ @router.get("", response_model=CardListResponse) def get_cards( - search: Optional[str] = Query(None), - category: Optional[str] = Query(None), - difficulty: Optional[Literal["easy", "normal", "hard"]] = Query(None), - tags: Optional[str] = Query(None), # через запятую: "list,dict" + search: str | None = Query(None), + category: str | None = Query(None), + difficulty: Literal["easy", "normal", "hard"] | None = Query(None), + tags: str | None = Query(None), # через запятую: "list,dict" page: int = Query(1, ge=1), per_page: int = Query(20, ge=1, le=500), db: Session = Depends(get_db), @@ -26,9 +26,7 @@ def get_cards( if search: term = f"%{search}%" - query = query.filter( - or_(Card.question.ilike(term), Card.answer.ilike(term)) - ) + query = query.filter(or_(Card.question.ilike(term), Card.answer.ilike(term))) if category: query = query.filter(Card.category == category) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 922e34d..ba14ae0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,6 +1,6 @@ import json from datetime import datetime -from typing import Literal, Optional +from typing import Literal from pydantic import BaseModel, field_validator @@ -8,7 +8,7 @@ class CardBase(BaseModel): question: str answer: str - code_example: Optional[str] = None + code_example: str | None = None category: str tags: list[str] = [] difficulty: Literal["easy", "normal", "hard"] = "normal" @@ -19,12 +19,12 @@ class CardCreate(CardBase): class CardUpdate(BaseModel): - question: Optional[str] = None - answer: Optional[str] = None - code_example: Optional[str] = None - category: Optional[str] = None - tags: Optional[list[str]] = None - difficulty: Optional[Literal["easy", "normal", "hard"]] = None + question: str | None = None + answer: str | None = None + code_example: str | None = None + category: str | None = None + tags: list[str] | None = None + difficulty: Literal["easy", "normal", "hard"] | None = None # Optional здесь означает - поле можно не передавать. # Для code_example null оставлен допустимым. diff --git a/backend/app/scripts/migrate_md.py b/backend/app/scripts/migrate_md.py index 87c7022..086086e 100644 --- a/backend/app/scripts/migrate_md.py +++ b/backend/app/scripts/migrate_md.py @@ -11,6 +11,7 @@ код пример ``` """ + import argparse import json import os @@ -53,14 +54,16 @@ def flush_card(): else: code = None - cards.append({ - "question": current_question.strip(), - "answer": answer, - "code_example": code, - "category": current_category, - "tags": [], - "difficulty": "normal", - }) + cards.append( + { + "question": current_question.strip(), + "answer": answer, + "code_example": code, + "category": current_category, + "tags": [], + "difficulty": "normal", + } + ) for line in lines: # отслеживает блоки кода @@ -111,7 +114,9 @@ def flush_card(): def main(): parser = argparse.ArgumentParser() parser.add_argument("--file", required=True, help="Path to questions.md") - parser.add_argument("--clear", action="store_true", help="Удалить все карточки перед импортом") + parser.add_argument( + "--clear", action="store_true", help="Удалить все карточки перед импортом" + ) args = parser.parse_args() with open(args.file, encoding="utf-8") as f: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7782816..56ca718 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,4 +17,15 @@ dev = [ "pytest>=8.0.0", "httpx>=0.27.0", "pytest-env>=1.1.0", -] \ No newline at end of file + "ruff>=0.16.10", +] + +[tool.ruff] +# Markdown файлы со скриптов миграции содержат контент карточек, форматировать нельзя +extend-exclude = ["*.md"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.Depends"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 69f73d8..682a336 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,4 +1,5 @@ import json + import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 0cb6d17..9c65f44 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -94,9 +94,7 @@ def test_unknown_id_returns_404(self, client): ) def test_null_for_required_field_rejected(self, client, db, field): card = make_card(db) - response = client.put( - f"{URL}/{card.id}", json={field: None}, headers=auth() - ) + response = client.put(f"{URL}/{card.id}", json={field: None}, headers=auth()) assert response.status_code == 422 # карточка не испортилась и по-прежнему читается assert client.get(f"/api/cards/{card.id}").status_code == 200 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 5b8b760..4d17bd0 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import jwt @@ -13,7 +13,7 @@ def auth_headers(token: str) -> dict: def make_jwt(sub="admin", key=None, expires_in=timedelta(hours=1)) -> str: - payload = {"sub": sub, "exp": datetime.now(timezone.utc) + expires_in} + payload = {"sub": sub, "exp": datetime.now(UTC) + expires_in} return jwt.encode(payload, key or settings.secret_key, algorithm="HS256") diff --git a/backend/tests/test_cards.py b/backend/tests/test_cards.py index ecdd797..d0aa6c8 100644 --- a/backend/tests/test_cards.py +++ b/backend/tests/test_cards.py @@ -1,5 +1,5 @@ import json -import pytest + from tests.conftest import make_card diff --git a/backend/uv.lock b/backend/uv.lock index 7518859..a83f774 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -344,6 +344,7 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-env" }, + { name = "ruff" }, ] [package.metadata] @@ -362,6 +363,7 @@ dev = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-env", specifier = ">=1.1.0" }, + { name = "ruff", specifier = ">=0.16.10" }, ] [[package]] @@ -409,6 +411,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + [[package]] name = "sqlalchemy" version = "2.1.3" diff --git a/bot/cards.py b/bot/cards.py index f456380..4b0c772 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -1,7 +1,9 @@ import json import random from html import escape + from sqlalchemy.orm import Session + from config import settings from models import Card @@ -10,12 +12,16 @@ _PRE_OPEN = '
'
_PRE_CLOSE = ""
+
def _difficulty_emoji(difficulty: str) -> str:
return {"easy": "🟢", "normal": "🟡", "hard": "🔴"}.get(difficulty, "⚪")
+
def get_next_cards(db: Session, count: int, last_id: int = 0) -> list[Card]:
"""Возвращает следующие карточки по порядку начиная с last_id."""
- cards = db.query(Card).filter(Card.id > last_id).order_by(Card.id).limit(count).all()
+ cards = (
+ db.query(Card).filter(Card.id > last_id).order_by(Card.id).limit(count).all()
+ )
if len(cards) < count:
extra = db.query(Card).order_by(Card.id).limit(count - len(cards)).all()
@@ -37,7 +43,8 @@ def format_card(card: Card) -> list[str]:
text = (
f"{difficulty_emoji} {escape(card.category)}\n\n"
f"❓ {escape(card.question)}\n\n"
- f"{escape(card.answer)}")
+ f"{escape(card.answer)}"
+ )
tags = json.loads(card.tags or "[]")
if tags:
@@ -76,6 +83,7 @@ def _split_code_telegram(code: str, limit: int = 4096) -> list[str]:
code = code[cut:].lstrip("\n")
return parts
+
def _split_telegram(text: str, limit: int = 4096) -> list[str]:
if len(text) <= limit:
return [text]
@@ -100,7 +108,8 @@ def format_card_discord(card: Card) -> list[str]:
text = (
f"{difficulty_emoji} **{card.category}**\n\n"
f"**```{card.question} ```**"
- f"{card.answer}\n")
+ f"{card.answer}\n"
+ )
tags = json.loads(card.tags or "[]")
if tags:
diff --git a/bot/config.py b/bot/config.py
index 019b5ed..8a31f9e 100644
--- a/bot/config.py
+++ b/bot/config.py
@@ -1,4 +1,5 @@
import os
+
from dotenv import load_dotenv
load_dotenv("../.env")
@@ -36,11 +37,14 @@ class Settings:
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./data/cards.db")
cards_per_session: int = int(os.getenv("CARDS_PER_SESSION", "4"))
# schedule_interval_hours: int = int(os.getenv("SCHEDULE_INTERVAL_HOURS", "5")) # не нужен при настройке почасового крона
- pause_between_cards_seconds: int = int(os.getenv("PAUSE_BETWEEN_CARDS_SECONDS", "240"))
+ pause_between_cards_seconds: int = int(
+ os.getenv("PAUSE_BETWEEN_CARDS_SECONDS", "240")
+ )
telegram_enabled: bool = os.getenv("TELEGRAM_ENABLED", "true").lower() == "true"
discord_webhook_url: str = os.getenv("DISCORD_WEBHOOK_URL", "")
discord_max_length: int = 2000
discord_timeout: int = 15
+
settings = Settings()
diff --git a/bot/discord_sender.py b/bot/discord_sender.py
index 1792015..d49fbd8 100644
--- a/bot/discord_sender.py
+++ b/bot/discord_sender.py
@@ -1,22 +1,24 @@
import asyncio
-import aiohttp
import logging
-from typing import Optional
+
+import aiohttp
+
from config import settings
log = logging.getLogger(__name__)
_MAX_ATTEMPTS = 8
-_NETWORK_RETRY_DELAY = 5 # пауза после сетевой ошибки, сек
-_DEFAULT_RETRY_AFTER = 5 # пауза при 429, если Discord не сказал сколько, сек
-_MAX_RETRY_AFTER = 60 # потолок паузы при 429, сек
+_NETWORK_RETRY_DELAY = 5 # пауза после сетевой ошибки, сек
+_DEFAULT_RETRY_AFTER = 5 # пауза при 429, если Discord не сказал сколько, сек
+_MAX_RETRY_AFTER = 60 # потолок паузы при 429, сек
+
class DiscordSender:
def __init__(self, webhook_url: str) -> None:
if not webhook_url:
raise ValueError("Discord webhook URL не задан")
self.webhook_url = webhook_url
- self.session: Optional[aiohttp.ClientSession] = None
+ self.session: aiohttp.ClientSession | None = None
async def start(self) -> None:
self.session = aiohttp.ClientSession(
@@ -56,19 +58,24 @@ async def _post(self, content: str) -> bool:
delay: float = _NETWORK_RETRY_DELAY
try:
async with self.session.post(
- self.webhook_url, json={"content": content}) as r:
+ self.webhook_url, json={"content": content}
+ ) as r:
if r.status in (200, 204):
return True
if r.status != 429:
- log.error(f"Discord: webhook вернул {r.status}: {await r.text()}")
+ log.error(
+ f"Discord: webhook вернул {r.status}: {await r.text()}"
+ )
return False
delay = await _read_retry_after(r)
log.warning(
f"Discord: rate limit, попытка {attempt}/{_MAX_ATTEMPTS}, "
- f"повтор через {delay} сек")
- except (aiohttp.ClientError, asyncio.TimeoutError) as e:
+ f"повтор через {delay} сек"
+ )
+ except (TimeoutError, aiohttp.ClientError) as e:
log.warning(
- f"Discord: ошибка сети, попытка {attempt}/{_MAX_ATTEMPTS}: {e}")
+ f"Discord: ошибка сети, попытка {attempt}/{_MAX_ATTEMPTS}: {e}"
+ )
if attempt < _MAX_ATTEMPTS:
await asyncio.sleep(delay)
@@ -76,6 +83,7 @@ async def _post(self, content: str) -> bool:
log.error(f"Discord: не удалось отправить за {_MAX_ATTEMPTS} попыток")
return False
+
async def _read_retry_after(r) -> float:
"""Достаёт retry_after из тела ответа 429.
При любой проблеме берёт значение по умолчанию.
@@ -86,6 +94,7 @@ async def _read_retry_after(r) -> float:
return _DEFAULT_RETRY_AFTER
return min(max(value, 0.0), _MAX_RETRY_AFTER)
+
def _split_into_chunks(text: str) -> list[str]:
limit = settings.discord_max_length
if len(text) <= limit:
diff --git a/bot/main.py b/bot/main.py
index 4a8476e..bf40402 100644
--- a/bot/main.py
+++ b/bot/main.py
@@ -1,7 +1,7 @@
import asyncio
import logging
+from datetime import UTC, datetime, timedelta
-from datetime import datetime, timedelta, timezone
from aiogram import Bot, Dispatcher, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
@@ -14,10 +14,11 @@
from discord_sender import DiscordSender
from state import load_last_id, save_last_id
-logging.basicConfig(level=logging.INFO,
- format="%(asctime)s %(levelname)s %(name)s: %(message)s",
- datefmt="%d-%m-%Y %H:%M:%S",
- )
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ datefmt="%d-%m-%Y %H:%M:%S",
+)
log = logging.getLogger(__name__)
bot = Bot(token=settings.bot_token)
@@ -40,7 +41,9 @@ async def send_card_to_telegram(chat_id: int, card) -> None:
await bot.send_message(chat_id, part, parse_mode="HTML")
break
except TelegramBadRequest as e:
- log.error(f"Telegram BadRequest карточка id={card.id} чат {chat_id}: {e}")
+ log.error(
+ f"Telegram BadRequest карточка id={card.id} чат {chat_id}: {e}"
+ )
break
except asyncio.CancelledError:
raise
@@ -55,7 +58,9 @@ async def send_card_to_telegram(chat_id: int, card) -> None:
await asyncio.sleep(delay)
-async def send_scheduled_cards(scheduler: AsyncIOScheduler, discord: DiscordSender) -> None:
+async def send_scheduled_cards(
+ scheduler: AsyncIOScheduler, discord: DiscordSender
+) -> None:
"""Отправляет серию карточек по расписанию всем пользователям.
При провале повторно отправляет через 15 минут.
# Discord и Telegram запускаются параллельно. Discord приоритетен
@@ -84,18 +89,21 @@ async def send_scheduled_cards(scheduler: AsyncIOScheduler, discord: DiscordSend
tg_task = asyncio.create_task(_send_tg_to_all(card))
discord_result, tg_result = await asyncio.gather(
- discord_task, tg_task, return_exceptions=True)
+ discord_task, tg_task, return_exceptions=True
+ )
if isinstance(discord_result, Exception):
log.error(
- f"Discord: исключение карточка id={card.id}: {discord_result}")
+ f"Discord: исключение карточка id={card.id}: {discord_result}"
+ )
elif not discord_result:
log.error(f"Discord: ошибка отправки карточка id={card.id}")
if isinstance(tg_result, Exception):
log.error(
f"Telegram: все попытки исчерпаны карточка id={card.id}: {tg_result}. "
- f"Повтор сессии через 15 минут.")
+ f"Повтор сессии через 15 минут."
+ )
save_last_id(last_sent_id)
_schedule_retry(scheduler, discord)
return
@@ -113,6 +121,7 @@ async def send_scheduled_cards(scheduler: AsyncIOScheduler, discord: DiscordSend
finally:
db.close()
+
async def _send_tg_to_all(card) -> None:
"""Отправляет карточку всем admin_ids в Telegram."""
for chat_id in settings.admin_ids:
@@ -121,7 +130,7 @@ async def _send_tg_to_all(card) -> None:
def _schedule_retry(scheduler: AsyncIOScheduler, discord: DiscordSender) -> None:
"""Планирует повторную попытку отправки через 15 мин."""
- run_at = datetime.now(timezone.utc) + timedelta(minutes=15)
+ run_at = datetime.now(UTC) + timedelta(minutes=15)
scheduler.add_job(
send_scheduled_cards,
trigger="date",
@@ -178,7 +187,7 @@ async def main() -> None:
scheduler = AsyncIOScheduler()
scheduler.add_job(
send_scheduled_cards,
- trigger="cron", # interval
+ trigger="cron", # interval
hour="7,12,17,22",
# hours=settings.schedule_interval_hours,
args=[scheduler, discord],
@@ -200,7 +209,8 @@ async def main() -> None:
await _run_polling()
else:
log.info(
- "Telegram отключён (TELEGRAM_ENABLED=false), работает только Discord")
+ "Telegram отключён (TELEGRAM_ENABLED=false), работает только Discord"
+ )
await asyncio.Event().wait()
finally:
await discord.stop()
diff --git a/bot/pyproject.toml b/bot/pyproject.toml
index 3f67070..62a71c5 100644
--- a/bot/pyproject.toml
+++ b/bot/pyproject.toml
@@ -13,5 +13,8 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.0.0",
+ "ruff>=0.16.10",
]
+[tool.ruff.lint]
+select = ["E4", "E7", "E9", "F", "I", "UP", "B"]
diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py
index 4808111..06d42a7 100644
--- a/bot/tests/test_cards.py
+++ b/bot/tests/test_cards.py
@@ -1,4 +1,9 @@
import json
+import os
+import sys
+from html import unescape
+from unittest.mock import MagicMock
+
from cards import (
_PRE_CLOSE,
_PRE_OPEN,
@@ -8,22 +13,9 @@
get_next_cards,
get_random_card,
)
-from html import unescape
-from unittest.mock import MagicMock
-
-import pytest
-import sys
-import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
-from cards import (
- _split_telegram,
- format_card,
- format_card_discord,
- get_next_cards,
- get_random_card,
-)
from config import settings
from models import Card
@@ -116,7 +108,9 @@ def test_code_example_in_separate_message(self):
def test_no_code_example(self):
card = make_card(code_example=None)
result = format_card(card)
- assert any("list" in part.lower() or "последовательность" in part for part in result)
+ assert any(
+ "list" in part.lower() or "последовательность" in part for part in result
+ )
def test_code_example_none_no_pre_tag(self):
card = make_card(code_example=None)
@@ -200,6 +194,7 @@ def test_returns_none_for_empty_db(self):
result = get_random_card(db)
assert result is None
+
class TestFormatCardDiscord:
def test_long_code_parts_within_discord_limit(self):
"""Каждое сообщение вместе с обёрткой ```python не длиннее лимита Discord."""
diff --git a/bot/tests/test_discord_sender.py b/bot/tests/test_discord_sender.py
index 2c509af..c0b8b80 100644
--- a/bot/tests/test_discord_sender.py
+++ b/bot/tests/test_discord_sender.py
@@ -11,6 +11,7 @@
class FakeResponse:
"""Подменяет ответ aiohttp: статус, json() и text()."""
+
def __init__(self, status, body=None, json_error=False):
self.status = status
self._body = body if body is not None else {}
@@ -33,6 +34,7 @@ async def __aexit__(self, *exc):
class FakeSession:
"""Отдаёт ответы по очереди; последний повторяется бесконечно."""
+
closed = False
def __init__(self, responses):
diff --git a/bot/tests/test_state.py b/bot/tests/test_state.py
index b57f735..bff71be 100644
--- a/bot/tests/test_state.py
+++ b/bot/tests/test_state.py
@@ -1,4 +1,3 @@
-import json
import os
import sys
diff --git a/bot/uv.lock b/bot/uv.lock
index 913079e..e88cab1 100644
--- a/bot/uv.lock
+++ b/bot/uv.lock
@@ -619,6 +619,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pytest" },
+ { name = "ruff" },
]
[package.metadata]
@@ -631,7 +632,10 @@ requires-dist = [
]
[package.metadata.requires-dev]
-dev = [{ name = "pytest", specifier = ">=8.0.0" }]
+dev = [
+ { name = "pytest", specifier = ">=8.0.0" },
+ { name = "ruff", specifier = ">=0.16.10" },
+]
[[package]]
name = "python-dotenv"
@@ -642,6 +646,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
+[[package]]
+name = "ruff"
+version = "0.16.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
+ { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
+ { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
+ { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
+ { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
+ { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.1.3"