Andrey Epifancev e705d75520 feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты
checks.py: тождество сальдо по каждой строке, суммы родителя = Σ детей того же data_type
(CURRENCY/QUANTITY исключены; обороты — по колонкам, сальдо — по колонкам или по нетто при
свёрнутом сальдо родителя), «Итого» = корневой счёт (R-13: нет корня — «Итого» пусто/0;
R-20: нет «Итого» — false и предупреждение).
osv.py: OsvParser — лимит размера, чтение по сигнатуре, листы по содержимому (формы ББ/ОФР и
прочие — пропуск с предупреждением), карта исходов D-6, кооперативный дедлайн, metrics
(rows без TOTAL, warnings с «sheet=<имя> hierarchy=<стратегия>» первой записью по листу).
get_parser(settings, rules_dir=…) → OsvParser (словари грузятся при создании: битый словарь —
падение старта); PARSE_MAX_BYTES / PARSE_TIMEOUT_SECONDS в Settings и .env.*; app.py передаёт
настройки. test_null_parser.py → test_parser_factory.py.
Тесты: юнит-карта статусов, «Испорченная сумма», «Пустая ОСВ», два листа ОСВ, новый синоним
без релиза кода (R-8); property-тест на 66 реальных файлах (все done, < 5 с, схема v1.1).
test_app и интеграционный round-trip ждут ошибку реального парсера вместо NOT_IMPLEMENTED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 14:37:21 +04:00

142 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Точка входа FastAPI сервиса tabular-parser.
Что здесь есть: настройки, подключение S3 (MinIO), NATS (`src/core/nats.py`: nkeys JWT, пустые seed/jwt — без
аутентификации, фоновое подключение — старт не ждёт NATS), воркер `src/core/worker.py` (подписка на
`recognition.tabular.request` после подключения к NATS), middleware (gzip, proxy headers), `/healthcheck`
(`nats = ok | down | off`, R-17), обработчики исключений. Сервис без БД и фронта (DECISION-4).
При `NATS_ENABLED=false` ни NATS, ни воркер не запускаются. Остановка: воркер перестаёт принимать запросы и
дорабатывает очередь не дольше `STOP_TIMEOUT` (NATS ещё нужен для публикации результатов), затем закрывается NATS
(drain не дольше `DRAIN_TIMEOUT`), затем сессия S3 — в сумме в пределах 30 с, которые платформа даёт на завершение.
"""
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from fastapi import FastAPI, Request
from loguru import logger
from miniopy_async import Minio
from starlette.middleware.gzip import GZipMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
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
from src.core.nats import NatsConnection
from src.core.worker import STOP_TIMEOUT, RecognitionWorker
from src.parser import get_parser
settings = get_settings()
VERSION = settings.VERSION
UP_TIME = datetime.now(UTC)
setup_logger(level="DEBUG" if settings.DEBUG else "INFO")
async def _connect_s3() -> S3Storage:
"""Клиент S3 с проверкой бакета.
Проверка — только диагностика: при недоступном MinIO или отсутствующем бакете ошибка логируется, а клиент
остаётся (соединения открываются на каждый запрос) — когда MinIO поднимется, воркер прочитает файл без рестарта.
"""
minio = settings.OPENBAO.MINIO
s3_client = Minio(
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(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),
)
else:
if bucket_exists:
logger.info("Бакет проверен. bucket={bucket}", bucket=minio.MINIO_BUCKET)
else:
logger.warning(
"Бакет не найден: чтение исходников и запись результатов будут падать. error_category=s3_error, "
"bucket={bucket}",
bucket=minio.MINIO_BUCKET,
)
return S3Storage(client=s3_client, bucket_name=minio.MINIO_BUCKET)
def _build_worker(storage: S3Storage) -> tuple[NatsConnection, RecognitionWorker]:
"""Соединение NATS и воркер; подписка на запросы — в `on_connected` (после первого подключения)."""
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_requests(worker.on_request)
nats = NatsConnection(settings.OPENBAO.NATS, on_connected=subscribe, publish_retries=settings.PUBLISH_RETRIES)
worker = RecognitionWorker(
storage=storage,
publisher=nats,
parser=get_parser(settings),
service_name=settings.SERVICE_NAME,
service_version=VERSION,
concurrency=settings.WORKER_CONCURRENCY,
done_cache_size=settings.DONE_CACHE_SIZE,
)
return nats, worker
@asynccontextmanager
async def lifespan(app: FastAPI):
storage = await _connect_s3()
nats: NatsConnection | None = None
worker: RecognitionWorker | None = None
if settings.NATS_ENABLED:
nats, worker = _build_worker(storage)
worker.start()
# start() не ждёт NATS: при недоступном сервере подключение продолжается в фоне (design D-6)
await nats.start()
else:
logger.info("NATS выключен (NATS_ENABLED=false): воркер не запускается")
app.state.storage = storage
app.state.nats = nats
app.state.worker = worker
try:
logger.info("Приложение запущено")
yield
finally:
logger.info("Приложение закрывается")
if worker is not None:
await worker.stop(STOP_TIMEOUT) # результаты ещё публикуются: NATS закрывается после воркера
if nats is not None:
await nats.stop()
await storage.client.close_session()
app = FastAPI(title="tabular-parser", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan)
def _nats_state(application: FastAPI) -> str:
"""`off` — NATS выключен флагом (R-17); `ok` — соединение есть; `down` — включён, но связи нет."""
if not settings.NATS_ENABLED:
return "off"
nats = getattr(application.state, "nats", None)
return "ok" if nats is not None and nats.is_connected else "down"
@app.get("/healthcheck", status_code=200)
async def healthcheck(request: Request):
return {"uptime": UP_TIME, "version": VERSION, "nats": _nats_state(request.app)}
app.include_router(router)
register_exception_handlers(app)
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"])