From 5229fe264c566048b5fa4284ebcce0601060d4b8 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 11:29:42 +0300 Subject: [PATCH 01/10] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20ruff=20=D0=B2=20backend=20=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pyproject.toml | 9 ++++++++- backend/uv.lock | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7782816..2b24652 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,4 +17,11 @@ 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.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] + +[tool.ruff.lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.Depends"] 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" From 7e8e5a1687225eb1d950ff9f8de3dc3feecc9d9d Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 11:31:50 +0300 Subject: [PATCH 02/10] =?UTF-8?q?style:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20ruff=20(Optional,=20UTC,=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D0=B2,=20=D0=BD=D0=B5=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D0=B5=D0=BC=D1=8B=D0=B9=20=D0=B8?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=80=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/auth.py | 4 ++-- backend/app/models.py | 8 ++++---- backend/app/routers/admin.py | 8 +++++++- backend/app/routers/cards.py | 10 +++++----- backend/app/schemas.py | 16 ++++++++-------- backend/tests/conftest.py | 1 + backend/tests/test_auth.py | 4 ++-- backend/tests/test_cards.py | 2 +- 8 files changed, 30 insertions(+), 23 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 1c37898..bb51c95 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,7 +10,7 @@ def create_token() -> str: - expire = datetime.now(timezone.utc) + timedelta(hours=settings.token_expire_hours) + expire = datetime.now(UTC) + timedelta(hours=settings.token_expire_hours) return jwt.encode({"sub": "admin", "exp": expire}, settings.secret_key, algorithm="HS256") diff --git a/backend/app/models.py b/backend/app/models.py index 8503fb2..baee198 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,9 @@ 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..e77136d 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"]) diff --git a/backend/app/routers/cards.py b/backend/app/routers/cards.py index 2882c74..c869700 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), 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/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_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 From 4a706e797623c8dfc04504d75cc37ebb1196481e Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 11:37:09 +0300 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=D1=8F=D0=B2=D0=BD=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D1=86=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D1=87=D0=BA=D1=83=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20verify=5Ftoken=20(B904)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/auth.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index bb51c95..7fa04c4 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -20,6 +20,12 @@ def verify_token(credentials: HTTPAuthorizationCredentials = Depends(bearer_sche 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 From 1849aa7ed785cab5cb98eacb845d2559a38b2a85 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 11:47:23 +0300 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20ruff=20=D0=B2=20bot=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/pyproject.toml | 3 +++ bot/uv.lock | 31 ++++++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) 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/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" From d994142b5a07f250eb93c14d86e3ec7124ce89b6 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 11:51:07 +0300 Subject: [PATCH 05/10] =?UTF-8?q?style:=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20ruff=20=D0=B2=20=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B5=20(=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B,=20Optiona?= =?UTF-8?q?l,=20UTC,=20=D0=B4=D1=83=D0=B1=D0=BB=D1=8C=20=D0=B8=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B0=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cards.py | 2 ++ bot/config.py | 1 + bot/discord_sender.py | 9 +++++---- bot/main.py | 4 ++-- bot/tests/test_cards.py | 18 +++++------------- bot/tests/test_state.py | 1 - 6 files changed, 15 insertions(+), 20 deletions(-) diff --git a/bot/cards.py b/bot/cards.py index f456380..1ac8492 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 diff --git a/bot/config.py b/bot/config.py index 019b5ed..29b4992 100644 --- a/bot/config.py +++ b/bot/config.py @@ -1,4 +1,5 @@ import os + from dotenv import load_dotenv load_dotenv("../.env") diff --git a/bot/discord_sender.py b/bot/discord_sender.py index 1792015..6d60893 100644 --- a/bot/discord_sender.py +++ b/bot/discord_sender.py @@ -1,7 +1,8 @@ import asyncio -import aiohttp import logging -from typing import Optional + +import aiohttp + from config import settings log = logging.getLogger(__name__) @@ -16,7 +17,7 @@ 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( @@ -66,7 +67,7 @@ async def _post(self, content: str) -> bool: log.warning( f"Discord: rate limit, попытка {attempt}/{_MAX_ATTEMPTS}, " f"повтор через {delay} сек") - except (aiohttp.ClientError, asyncio.TimeoutError) as e: + except (TimeoutError, aiohttp.ClientError) as e: log.warning( f"Discord: ошибка сети, попытка {attempt}/{_MAX_ATTEMPTS}: {e}") diff --git a/bot/main.py b/bot/main.py index 4a8476e..5320d65 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 @@ -121,7 +121,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", diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index 4808111..5467fa5 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 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 From d0b1b00089531361627d69b91575189529d797ab Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 11:54:00 +0300 Subject: [PATCH 06/10] =?UTF-8?q?ci:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20ruff=20=D0=B2=20backend=20=D0=B8=20bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2378968..975ce57 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 + - name: Run tests working-directory: backend env: @@ -41,9 +45,13 @@ jobs: working-directory: bot run: uv sync + - name: Lint (ruff) + working-directory: bot + run: uv run ruff check + - name: Run tests working-directory: bot env: BOT_TOKEN: "fake_token_for_tests" ADMIN_IDS: "123456" - run: uv run pytest tests/ -v + run: uv run pytest tests/ -v \ No newline at end of file From 6507c9e2c782aa9b36e8022403eba902804b284e Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 12:09:13 +0300 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20Markdown=20=D0=B8=D0=B7=20ruff=20for?= =?UTF-8?q?mat=20(=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D1=82=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2b24652..56ca718 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,10 @@ dev = [ "ruff>=0.16.10", ] +[tool.ruff] +# Markdown файлы со скриптов миграции содержат контент карточек, форматировать нельзя +extend-exclude = ["*.md"] + [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I", "UP", "B"] From 2e246d357d2e0850d601c9d46fe9946510431997 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 12:11:53 +0300 Subject: [PATCH 08/10] =?UTF-8?q?style:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=20backend=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20ruff=20form?= =?UTF-8?q?at?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/auth.py | 14 ++++++++------ backend/app/config.py | 1 + backend/app/models.py | 5 +++-- backend/app/routers/admin.py | 14 +++++++++++--- backend/app/routers/cards.py | 4 +--- backend/app/scripts/migrate_md.py | 23 ++++++++++++++--------- backend/tests/test_admin.py | 4 +--- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/backend/app/auth.py b/backend/app/auth.py index 7fa04c4..0b68566 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -11,21 +11,23 @@ def create_token() -> str: expire = datetime.now(UTC) + timedelta(hours=settings.token_expire_hours) - return jwt.encode({"sub": "admin", "exp": expire}, settings.secret_key, algorithm="HS256") + 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" + 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" + 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 baee198..e12373e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -19,8 +19,9 @@ class Card(Base): tags = Column(Text, default="[]") difficulty = Column(String(10), nullable=False, default="normal") created_at = Column(DateTime, default=lambda: datetime.now(UTC)) - updated_at = Column(DateTime, default=lambda: datetime.now(UTC), - onupdate=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 e77136d..dc9517c 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -27,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, @@ -47,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 c869700..dce215a 100644 --- a/backend/app/routers/cards.py +++ b/backend/app/routers/cards.py @@ -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/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/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 From f53e1f6f275dd096493ba0eb310d8e5c04cec628 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 12:17:50 +0300 Subject: [PATCH 09/10] =?UTF-8?q?style:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20ruff=20fo?= =?UTF-8?q?rmat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cards.py | 13 ++++++++++--- bot/config.py | 5 ++++- bot/discord_sender.py | 22 +++++++++++++++------- bot/main.py | 32 +++++++++++++++++++++----------- bot/tests/test_cards.py | 5 ++++- bot/tests/test_discord_sender.py | 2 ++ 6 files changed, 56 insertions(+), 23 deletions(-) diff --git a/bot/cards.py b/bot/cards.py index 1ac8492..4b0c772 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -12,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() @@ -39,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: @@ -78,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] @@ -102,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 29b4992..8a31f9e 100644 --- a/bot/config.py +++ b/bot/config.py @@ -37,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 6d60893..d49fbd8 100644 --- a/bot/discord_sender.py +++ b/bot/discord_sender.py @@ -8,9 +8,10 @@ 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: @@ -57,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} сек") + 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) @@ -77,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. При любой проблеме берёт значение по умолчанию. @@ -87,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 5320d65..bf40402 100644 --- a/bot/main.py +++ b/bot/main.py @@ -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: @@ -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/tests/test_cards.py b/bot/tests/test_cards.py index 5467fa5..06d42a7 100644 --- a/bot/tests/test_cards.py +++ b/bot/tests/test_cards.py @@ -108,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) @@ -192,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): From 5ed548a4f4e239aa1819dd68b85181916f011d06 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 12:19:29 +0300 Subject: [PATCH 10/10] =?UTF-8?q?ci:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20ruff=20format=20--check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 975ce57..dced61f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: - name: Lint (ruff) working-directory: backend - run: uv run ruff check + run: uv run ruff check && uv run ruff format --check - name: Run tests working-directory: backend @@ -47,11 +47,11 @@ jobs: - name: Lint (ruff) working-directory: bot - run: uv run ruff check + run: uv run ruff check && uv run ruff format --check - name: Run tests working-directory: bot env: BOT_TOKEN: "fake_token_for_tests" ADMIN_IDS: "123456" - run: uv run pytest tests/ -v \ No newline at end of file + run: uv run pytest tests/ -v