From 9cdc62b26d16ce7d35c6d881f7291073813ceabf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 02:25:29 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D1=83=D0=BF=D1=80=D0=BE=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20app.py,=20config.py=20=D0=B8=20requirements.txt?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=8B=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убраны SPA, CORS, SMB, РАИСА, SQLAlchemy-обработчики и зависимости БД/SMB; настройки: ENV_FILE, SERVICE_NAME, NATS_ENABLED, WORKER_CONCURRENCY, PUBLISH_RETRIES, DONE_CACHE_SIZE, MINIO_SECURE. Co-Authored-By: Claude Fable 5.1 --- api/requirements.txt | 9 - api/src/app.py | 177 +++++------------- api/src/core/config.py | 52 +++-- api/src/core/exception_handlers.py | 15 -- .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 6 files changed, 86 insertions(+), 171 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index f26f6d9..baf800b 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,13 +2,9 @@ aiohappyeyeballs==2.7.1 aiohttp==3.14.3 aiohttp-retry==2.9.1 aiosignal==1.4.0 -alembic==1.14.0 annotated-doc==0.0.4 annotated-types==0.8.0 anyio==4.14.2 -argon2-cffi==23.1.0 -argon2-cffi-bindings==25.1.0 -asyncpg==0.30.0 attrs==26.1.0 certifi==2025.11.12 cffi==2.1.0 @@ -20,7 +16,6 @@ fastapi-cli==0.0.24 fastapi-cloud-cli==0.14.1 fastar==0.8.0 frozenlist==1.8.0 -greenlet==3.4.0 h11==0.16.0 httpcore==1.0.9 httptools==0.7.1 @@ -28,7 +23,6 @@ httpx==0.28.1 idna==3.18 Jinja2==3.1.6 loguru==0.7.3 -Mako==1.3.10 markdown-it-py==4.2.0 MarkupSafe==3.0.3 mdurl==0.1.2 @@ -48,14 +42,11 @@ PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 PyYAML==6.0.3 -raisa_fastapi_protected_api==1.0.0 rich==15.0.0 rich-toolkit==0.19.7 rignore==0.7.6 sentry-sdk==2.66.1 shellingham==1.5.4 -SQLAlchemy==2.0.36 -smbprotocol==1.15.0 starlette==1.3.1 typer==0.27.0 typing-inspection==0.4.2 diff --git a/api/src/app.py b/api/src/app.py index 845e7c0..d994ecd 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -1,21 +1,16 @@ -"""Точка входа FastAPI. Каркас из шаблона команды: инфраструктура без доменного кода. +"""Точка входа FastAPI сервиса tabular-parser. -Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT), SMB (горячие папки ИС Сканирование), -middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=False), healthcheck, раздача собранного SPA. -Что добавляет Срез 0: роутеры в src/api/router.py, outbox-паблишер и consumer recognition.result -(docs/context/03-architecture.md), зависимость require(permission). +Что здесь есть: настройки, подключение S3 (MinIO), NATS (nkeys JWT; пустые seed/jwt — без аутентификации), +middleware (gzip, proxy headers), healthcheck, обработчики исключений. Сервис без БД и фронта (DECISION-4). +Воркер обработки `recognition.request` и поле `nats` в healthcheck добавляются позже (src/core/nats.py). """ + import base64 -import os from contextlib import asynccontextmanager -from datetime import datetime, timezone +from datetime import UTC, datetime import nkeys -import smbclient from fastapi import FastAPI -from fastapi.exceptions import HTTPException, StarletteHTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from loguru import logger from miniopy_async import Minio from nats.aio.client import Client as NATS @@ -26,120 +21,86 @@ from src.api.router import router from src.core.clients import S3Storage from src.core.config import get_settings from src.core.exception_handlers import register_exception_handlers +from src.core.logger import setup_logger settings = get_settings() VERSION = settings.VERSION -UP_TIME = datetime.now(timezone.utc) +UP_TIME = datetime.now(UTC) + +setup_logger(level="DEBUG" if settings.DEBUG else "INFO") -# --- NATS: аутентификация по nkeys/JWT --------------------------------------------------------- -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode("utf-8")) +async def _connect_nats() -> NATS | None: + """Подключение к NATS (core, без JetStream); при ошибке — ``None``, приложение всё равно стартует.""" + nats_settings = settings.OPENBAO.NATS + options: dict = {"max_reconnect_attempts": 2} + if nats_settings.NATS_SEED and nats_settings.NATS_JWT: + kp = nkeys.from_seed(nats_settings.NATS_SEED.encode("utf-8")) + def sig_cb(nonce: bytes | str) -> bytes: + if isinstance(nonce, str): + nonce = nonce.encode("utf-8") + return base64.b64encode(kp.sign(nonce)) -def jwt_cb(): - return settings.OPENBAO.NATS.NATS_JWT.encode("utf-8") - - -def sig_cb(nonce): - if isinstance(nonce, str): - nonce = nonce.encode("utf-8") - return base64.b64encode(kp.sign(nonce)) - - -# --- РАИСА: защищённый API (только вне DEBUG) --------------------------------------------------- -protected_middleware_available = False -if not settings.DEBUG: + options |= {"user_jwt_cb": lambda: nats_settings.NATS_JWT.encode("utf-8"), "signature_cb": sig_cb} + nc = NATS() try: - from raisa_fastapi_protected_api import ( # pyright: ignore[reportMissingImports] - AuthorizationMiddleware, - OpenEndpoint, - ProtectedOAuthSettings, - ProtectedRolesSettings, - ProtectedSettings, - SearchType, + await nc.connect(nats_settings.NATS_URL, **options) + except Exception as exc: + logger.exception( + "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", + error_type=type(exc).__name__, + error=repr(exc), ) - - protected_middleware_available = True - except ImportError: - protected_middleware_available = False + return None + logger.info("Подключение к NATS произошло") + return nc -def _normalize_root_path(value: str) -> str: - if not value: - return "" - value = value.strip() - if value == "/": - return "" - return "/" + value.strip("/") - - -_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - app.state.nats_subs = [] - app.state.storage = None - app.state.nats = None - - try: - smbclient.register_session( - settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD - ) - except Exception: - logger.exception("Ошибка при регистрации сессии SMB") - +async def _connect_s3() -> S3Storage | None: + """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" + minio = settings.OPENBAO.MINIO s3_client = Minio( - settings.OPENBAO.MINIO.MINIO_URL, - access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, - secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, + minio.MINIO_URL, + access_key=minio.MINIO_ADMIN_LOGIN, + secret_key=minio.MINIO_ADMIN_PASSWORD, + secure=minio.MINIO_SECURE, cert_check=False, ) try: - bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) - logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) + bucket_exists = await s3_client.bucket_exists(minio.MINIO_BUCKET) except Exception as exc: logger.exception( "Не удалось проверить наличие бакета. error_category=s3_error, error_type={error_type}, error={error}", error_type=type(exc).__name__, error=repr(exc), ) - return - app.state.storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) + await s3_client.close_session() + return None + logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) + return S3Storage(client=s3_client, bucket_name=minio.MINIO_BUCKET) - nc = NATS() - if not settings.DEBUG: - try: - await nc.connect( - settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, max_reconnect_attempts=2 - ) - logger.info("Подключение к NATS произошло") - except Exception as exc: - logger.exception( - "Не удалось подключиться к NATS. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - app.state.nats = nc - # Подписки (recognition.result) и outbox-паблишер — Срез 0, см. docs/context/03-architecture.md + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.storage = await _connect_s3() + app.state.nats = await _connect_nats() if settings.NATS_ENABLED else None + if not settings.NATS_ENABLED: + logger.info("NATS выключен (NATS_ENABLED=false)") try: logger.info("Приложение запущено") yield finally: logger.info("Приложение закрывается") - smbclient.reset_connection_cache() - for sub in app.state.nats_subs: - await sub.unsubscribe() - if app.state.nats and not settings.DEBUG: + if app.state.nats: await app.state.nats.close() if app.state.storage: await app.state.storage.client.close_session() -app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) +app = FastAPI(title="tabular-parser", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) @app.get("/healthcheck", status_code=200) @@ -153,37 +114,3 @@ register_exception_handlers(app) app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -# --- Раздача собранного SPA (web/) --------------------------------------------------------------- -os.makedirs("web", exist_ok=True) - - -def sep_static(path: str, stat_dir: str) -> str: - sep_path = path.split("/") - index = sep_path.index(stat_dir) - return "/".join(sep_path[index:]) - - -class SPAStaticFiles(StaticFiles): - async def get_response(self, path: str, scope): - try: - return await super().get_response(path, scope) - except (HTTPException, StarletteHTTPException) as ex: - if ex.status_code == 404: - if "images" in path: - return await super().get_response(sep_static(path, "images"), scope) - if "static" in path: - return await super().get_response(sep_static(path, "static"), scope) - return await super().get_response("index.html", scope) - raise - - -app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..9e4fd8c 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,50 +1,62 @@ +import os + +from pydantic import PositiveInt from pydantic_settings import BaseSettings, SettingsConfigDict -class DBSettings(BaseSettings): - DATABASE_URL: str - class MinioSettings(BaseSettings): MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str MINIO_BUCKET: str + MINIO_SECURE: bool = True # https (платформа); false — http (MinIO из docker compose) + class NATSSettings(BaseSettings): NATS_URL: str - NATS_SEED: str - NATS_JWT: str + NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты) + NATS_JWT: str = "" -class SMBSettings(BaseSettings): - SMB_HOST: str - SMB_PATH: str - SMB_LOGIN: str - SMB_PASSWORD: str class OpenBAOSettings(BaseSettings): - DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings - SMB: SMBSettings + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" PORT: int = 8000 + class Settings(BaseSettings): + """Настройки сервиса. + + Файл окружения выбирается переменной ``ENV_FILE`` (по умолчанию ``.env``; значение читается при импорте модуля, + относительный путь считается от текущего каталога — ``api/``). Тесты используют ``.env.test``. + """ + OPENBAO: OpenBAOSettings DEBUG: bool = False server: ServerSettings = ServerSettings() - ROOT_PATH: str = '/' - VERSION: str = '1.0.0' + ROOT_PATH: str = "/" + VERSION: str = "1.0.0" + + SERVICE_NAME: str = "tabular-parser" # `service.name` конвертов контракта и имя queue group NATS + NATS_ENABLED: bool = True # false — приложение не подключается к NATS (воркер не стартует) + WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно + PUBLISH_RETRIES: PositiveInt = 3 # попытки публикации ack/результата в NATS + DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id + + model_config = SettingsConfigDict( + extra="ignore", + env_file=os.environ.get("ENV_FILE", ".env"), + env_nested_delimiter="__", + nested_model_default_partial_update=False, + ) - class Config: - extra = "ignore" - env_file = ".env" - env_nested_delimiter = "__" - nested_model_default_partial_update = False def get_settings() -> Settings: return Settings() -settings = get_settings() \ No newline at end of file + +settings = get_settings() diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index f7643c2..2f7b412 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,7 +1,6 @@ from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from sqlalchemy.exc import IntegrityError, SQLAlchemyError def register_exception_handlers(app: FastAPI) -> None: @@ -12,20 +11,6 @@ def register_exception_handlers(app: FastAPI) -> None: content={"detail": exc.errors()}, ) - @app.exception_handler(IntegrityError) - async def integrity_error_handler(_: Request, exc: IntegrityError): - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={"detail": "нарушение целостности данных в БД"}, - ) - - @app.exception_handler(SQLAlchemyError) - async def sqlalchemy_error_handler(_: Request, exc: SQLAlchemyError): - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "ошибка базы данных"}, - ) - @app.exception_handler(HTTPException) async def http_exception_handler(_: Request, exc: HTTPException): # Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail. diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index 8cdbdca..8dfba80 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -23,7 +23,7 @@ ### Task 2: [1.2] `src/app.py` -- [ ] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200 +- [x] `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`; проверка: `pip install -r requirements.txt` в чистом venv (python3.11), `python run_server.py` стартует c `.env.example`, `/healthcheck` 200 ### Task 3: [1.3] `README.md` — описание сервиса (назначение, команды `make`, env, запуск против c diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index 01f128d..b3ef57e 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -8,7 +8,7 @@ - [x] 1.1 Удалить `web/`, `setup/.npmrc`, npm-шаги из `setup/setup.sh`, `api/alembic/`, `api/alembic.ini`, `api/src/db/`, `api/src/models/`, `api/src/repositories/`; из `.gitignore` убрать `Dockerfile`; проверка: `git status` показывает только удаления и `.gitignore`; `python -c "import src"` пока падает ожидаемо (чинится в 1.2) -- [ ] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; +- [x] 1.2 `src/app.py`: убрать mount SPA/`os.makedirs("web")`, CORS, SMB-сессию, импорт `raisa_fastapi_protected_api`; `src/core/config.py`: убрать `DBSettings`, `SMBSettings`, оставить `MINIO`, `NATS`; добавить `ENV_FILE`, `SERVICE_NAME="tabular-parser"`, `NATS_ENABLED=true`, `WORKER_CONCURRENCY=1`, `PUBLISH_RETRIES=3`, `DONE_CACHE_SIZE=1000`; `requirements.txt` без `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `raisa_fastapi_protected_api`;