chore: упрощены app.py, config.py и requirements.txt после выреза шаблона

Убраны 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 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 02:25:29 +04:00
parent b428f68e9e
commit 9cdc62b26d
6 changed files with 86 additions and 171 deletions

View File

@ -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

View File

@ -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")

View File

@ -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()
settings = get_settings()

View File

@ -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.

View File

@ -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

View File

@ -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`;