Andrey Epifancev b489f6e0b3 Срез 0 [4.3]: эмулятор ИС Сканирование (src/scan_emulator), unit-тесты, отметка задачи
process_once(root, fixtures, delay): новые <uuid>_<имя> -> ответ из фикстуры (точное имя, затем префикс,
file_uid подставляется) либо INVALID_DOCUMENT, атомарная запись ответа, затем переименование в .done.
Цикл python -m src.scan_emulator с корректной остановкой по SIGINT/SIGTERM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 03:10:36 +04:00

221 lines
11 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.

"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator).
Воспроизводит протокол горячих папок: для нового файла `<uuid>_<name>.<ext>` во входящем каталоге через задержку кладёт
`<output>/<uuid>/<uuid>.json` с эталонным ответом из `SCAN_FIXTURES_DIR` и переименовывает входящий файл в `….done`.
Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не настройки шлюза: в его контейнере нет
переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`, тем самым тестируя и его.
"""
import json
import math
import os
import re
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from dotenv import dotenv_values
from loguru import logger
from src.gateway.hotfolder import LocalHotFolder
__all__ = ["EmulatorSettings", "process_once", "run"]
# `<uuid>_<name>`: канонический UUID (36 символов) и непустое имя после `_`
_INBOX_NAME = re.compile(
r"^(?P<uuid>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})_(?P<name>.+)$"
)
_DONE_SUFFIX = ".done"
_ERROR_CODE = "INVALID_DOCUMENT"
def _read_float(env: Mapping[str, str], name: str, default: float, *, positive: bool) -> float:
raw = env.get(name)
if raw is None or raw.strip() == "":
return default
try:
value = float(raw)
except ValueError:
raise ValueError(f"{name}: ожидается число, получено {raw!r}") from None
if not math.isfinite(value) or value < 0 or (positive and value == 0):
raise ValueError(
f"{name}: ожидается {'положительное' if positive else 'неотрицательное'} число, получено {raw!r}"
)
return value
@dataclass(frozen=True)
class EmulatorSettings:
"""Настройки эмулятора из переменных окружения (имена совпадают с `.env.example`)."""
hotfolder_path: Path
input_dir: str = "Input"
output_dir: str = "Output"
fixtures_dir: Path = Path("../../fixtures/ocr")
delay: float = 2.0
poll_interval: float = 1.0
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "EmulatorSettings":
"""Разбор окружения. Без аргумента: `os.environ` поверх файла `ENV_FILE` (по умолчанию `.env`, если он есть).
`SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output,
`SCAN_FIXTURES_DIR`=../../fixtures/ocr, `SCAN_EMULATOR_DELAY`=2 (с, >= 0),
`SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0).
"""
if environ is None:
env_file = os.environ.get("ENV_FILE", ".env")
file_values = {k: v for k, v in dotenv_values(env_file).items() if v is not None}
env: Mapping[str, str] = {**file_values, **os.environ}
else:
env = environ
path = env.get("SCAN_HOTFOLDER_PATH", "").strip()
if not path:
raise ValueError("SCAN_HOTFOLDER_PATH не задан: укажите корень горячей папки (каталог с Input/ и Output/)")
return cls(
hotfolder_path=Path(path),
input_dir=env.get("SCAN_INPUT_DIR") or "Input",
output_dir=env.get("SCAN_OUTPUT_DIR") or "Output",
fixtures_dir=Path(env.get("SCAN_FIXTURES_DIR") or "../../fixtures/ocr"),
delay=_read_float(env, "SCAN_EMULATOR_DELAY", 2.0, positive=False),
poll_interval=_read_float(env, "SCAN_EMULATOR_POLL_SECONDS", 1.0, positive=True),
)
def _error_answer(job_id: str, message: str) -> dict[str, Any]:
return {"file_uid": job_id, "error": {"code": _ERROR_CODE, "message": message}, "data_ocr": []}
def _find_fixture(fixtures: Path, stem: str) -> Path | None:
"""`<stem>.json`, затем `<stem>*.json` (первый по алфавиту). Имя сравнивается буквально, без glob."""
try:
names = sorted(p.name for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file())
except OSError:
return None
if f"{stem}.json" in names:
return fixtures / f"{stem}.json"
for name in names:
if name.startswith(stem):
return fixtures / name
return None
def _build_answer(job_id: str, name: str, fixtures: Path) -> dict[str, Any]:
"""Ответ ИС на файл `name` (без префикса uuid): фикстура с подставленным `file_uid` либо ошибка Content AI."""
fixture = _find_fixture(fixtures, Path(name).stem)
if fixture is None:
return _error_answer(job_id, f"{name}: нет эталонного ответа в фикстурах")
try:
data = json.loads(fixture.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("корень не объект JSON")
except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError
logger.error("Эмулятор: фикстура {} повреждена ({}); для {} отдаём {}", fixture, exc, name, _ERROR_CODE)
return _error_answer(job_id, f"{name}: эталонный ответ {fixture.name} повреждён и не разобран")
return {"file_uid": job_id, **{key: value for key, value in data.items() if key != "file_uid"}}
def _write_answer(hotfolder: LocalHotFolder, job_id: str, answer: dict[str, Any]) -> None:
"""Атомарная запись ответа: `.part` в том же каталоге, затем `os.replace`."""
target = hotfolder.answer_path(job_id)
target.parent.mkdir(parents=True, exist_ok=True)
part = target.parent / f".{target.name}.part"
try:
part.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(part, target)
except BaseException:
part.unlink(missing_ok=True)
raise
def process_once(
root: Path | str,
fixtures: Path | str,
delay: float,
*,
input_dir: str = "Input",
output_dir: str = "Output",
) -> list[str]:
"""Один проход по входящему каталогу; возвращает uuid обработанных файлов.
Берёт новые `<uuid>_<name>.<ext>` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход
ждёт `delay` секунд, для каждого файла пишет `<output>/<uuid>/<uuid>.json` и только затем переименовывает входящий
файл в `….done`. Имена не по шаблону `<uuid>_` игнорируются (одно предупреждение за проход). Нет входящего
каталога — пустой результат.
"""
hotfolder = LocalHotFolder(root, input_dir, output_dir)
fixtures_dir = Path(fixtures)
try:
entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name)
except FileNotFoundError:
return []
pending: list[tuple[Path, str, str]] = []
foreign: list[str] = []
for entry in entries:
if entry.name.startswith(".") or entry.name.endswith(_DONE_SUFFIX) or not entry.is_file():
continue
match = _INBOX_NAME.match(entry.name)
if match is None:
foreign.append(entry.name)
continue
pending.append((entry, match["uuid"], match["name"]))
if foreign:
logger.warning("Эмулятор: файлы не по шаблону <uuid>_<имя> пропущены: {}", ", ".join(foreign))
if not pending:
return []
if delay > 0:
time.sleep(delay)
processed: list[str] = []
for entry, job_id, name in pending:
try:
_write_answer(hotfolder, job_id, _build_answer(job_id, name, fixtures_dir))
os.replace(entry, entry.with_name(entry.name + _DONE_SUFFIX))
except FileNotFoundError:
logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name)
continue
processed.append(job_id)
logger.info("Эмулятор: {} -> {}", entry.name, hotfolder.answer_path(job_id))
return processed
def _count_fixtures(fixtures: Path) -> int:
return sum(1 for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file())
def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None:
"""Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено
`max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл."""
hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir)
logger.info(
"Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с",
hotfolder.root,
hotfolder.input_dir,
hotfolder.output_dir,
settings.delay,
settings.poll_interval,
)
if settings.fixtures_dir.is_dir():
logger.info("Фикстуры: {}, фикстур: {}", settings.fixtures_dir, _count_fixtures(settings.fixtures_dir))
else:
logger.warning(
"Каталог фикстур {} не найден: на все файлы будет отвечать {}", settings.fixtures_dir, _ERROR_CODE
)
iterations = 0
while not stop.is_set() and (max_iterations is None or iterations < max_iterations):
try:
process_once(
settings.hotfolder_path,
settings.fixtures_dir,
settings.delay,
input_dir=settings.input_dir,
output_dir=settings.output_dir,
)
except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят
logger.exception("Эмулятор: сбой прохода по входящему каталогу")
iterations += 1
stop.wait(settings.poll_interval)
logger.info("Эмулятор остановлен")