"""Общие основы моделей контракта v1.1: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки. Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false` (кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) — так обратно совместимый MINOR-релиз (необязательные поля) не ломает потребителя. Типы строгие (`strict=True`): JSON Schema не приводит `"1"` к числу, а число — к строке; поля со строковой записью (uuid, date-time, date, деньги) разбирают строки явно. """ import re import uuid from datetime import UTC, date, datetime from decimal import Decimal from enum import StrEnum from typing import Annotated, Any, Literal, TypeVar from pydantic import ( AfterValidator, AwareDatetime, BaseModel, BeforeValidator, ConfigDict, Field, PlainSerializer, PlainValidator, WithJsonSchema, model_validator, ) CONTRACT_VERSION = "1.2" # Шаблоны версий — буквально из схем SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR RESULT_SCHEMA_VERSION_PATTERN = r"^\d+\.\d+$" # recognition-result-message.result_schema_version T = TypeVar("T") class _NotNull: """Метка поля, необязательного в схеме, но не допускающего явный `null` (в схеме тип без `null`).""" NOT_NULL = _NotNull() # Необязательное поле без `null`: отсутствует (None в модели) или значение типа T; явный null во входе — ошибка OptionalNotNull = Annotated[T | None, NOT_NULL] class ContractModel(BaseModel): """База моделей контракта: неизменяемые, строгие, незнакомые поля отбрасываются.""" model_config = ConfigDict(extra="ignore", strict=True, frozen=True) @model_validator(mode="before") @classmethod def _reject_explicit_null(cls, data: Any) -> Any: """Схема не допускает `null`, где тип без `null`: `{"result_s3": null}` — ошибка, а не «нет поля».""" if isinstance(data, dict): for name, field in cls.model_fields.items(): key = field.alias or name if NOT_NULL in field.metadata and key in data and data[key] is None: raise ValueError(f"поле {key!r} не допускает null (в схеме контракта нет null в типе)") return data def to_wire(self) -> dict[str, Any]: """Тело для NATS / файла S3: JSON-словарь только из заданных полей (uuid, время, деньги — строки).""" return self.model_dump(mode="json", exclude_unset=True) # --- UUID ----------------------------------------------------------------------------------------------------------- _UUID_RE = re.compile(r"[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}", re.ASCII) def _require_canonical_uuid(value: object) -> object: """Строка канонического вида 8-4-4-4-12 (как `format: uuid` схемы) либо готовый `uuid.UUID`.""" if isinstance(value, uuid.UUID) or (isinstance(value, str) and _UUID_RE.fullmatch(value)): return value raise ValueError("ожидается uuid строкой вида xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx") ContractUUID = Annotated[uuid.UUID, BeforeValidator(_require_canonical_uuid), Field(strict=False)] # --- время ---------------------------------------------------------------------------------------------------------- _DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII) def _require_text_or_datetime(value: object) -> object: if isinstance(value, str | datetime): return value raise ValueError("ожидается строка date-time (RFC 3339) с часовым поясом") def _to_utc(value: datetime) -> datetime: return value.astimezone(UTC) # `reply_deadline`: срок ответа, UTC. Часовой пояс обязателен (aware); смещение приводится к UTC UtcDatetime = Annotated[ AwareDatetime, BeforeValidator(_require_text_or_datetime), AfterValidator(_to_utc), Field(strict=False), ] def _require_iso_date(value: object) -> object: if isinstance(value, date) and not isinstance(value, datetime): return value if isinstance(value, str) and _DATE_RE.fullmatch(value): return value raise ValueError("ожидается дата строкой YYYY-MM-DD") ContractDate = Annotated[date, BeforeValidator(_require_iso_date), Field(strict=False)] # --- деньги --------------------------------------------------------------------------------------------------------- MONEY_PATTERN = r"^-?\d+(\.\d{1,2})?$" # буквально из `$defs.money` _MONEY_RE = re.compile(r"-?\d+(\.\d{1,2})?", re.ASCII) def _parse_money(value: object) -> Decimal | None: """Сумма — строка по шаблону схемы (копейки не теряются) или `null`; `float` / `int` — ошибка.""" if value is None: return None if isinstance(value, Decimal): text = format(value, "f") if value.is_finite() else "" elif isinstance(value, str): text = value else: raise ValueError( "сумма должна быть строкой вида '106311116.46' (float и int недопустимы, чтобы не терять копейки)" ) if not _MONEY_RE.fullmatch(text): raise ValueError(f"сумма {text!r} не соответствует шаблону {MONEY_PATTERN} (до двух знаков после точки)") return Decimal(text) def _dump_money(value: Decimal | None) -> str | None: return None if value is None else format(value, "f") # В модели — `Decimal`; в JSON — строка ровно в той записи, как пришла («1100», «-12.30») Money = Annotated[ Decimal | None, PlainValidator(_parse_money), PlainSerializer(_dump_money, return_type=str | None, when_used="json"), WithJsonSchema({"type": ["string", "null"], "pattern": MONEY_PATTERN}), ] # --- целые ----------------------------------------------------------------------------------------------------------- def int_like(value: object) -> object: """JSON Schema 2020-12 считает `1.0` целым: `float` с целым значением приводится к `int` (pandas пишет `5.0`). `bool` (в JSON это не число) отвергается явно; нецелые `float` (в том числе NaN и inf) и строки остаются как есть и отвергаются строгой проверкой `int`. """ if isinstance(value, bool): raise ValueError("ожидается целое число, а не булево значение") if isinstance(value, float) and value.is_integer(): return int(value) return value # Целое по схеме (`type: integer`): `int` или целочисленный `float`; bool, строка и `1.5` — ошибка IntLike = Annotated[int, BeforeValidator(int_like)] # `header_variant`: enum [1, 2] схемы; `True` и `1.5` отвергаются, `1.0` равно 1 HeaderVariant = Annotated[Literal[1, 2], BeforeValidator(int_like)] # --- скаляры -------------------------------------------------------------------------------------------------------- NonEmptyStr = Annotated[str, Field(min_length=1)] SchemaVersion = Annotated[str, Field(pattern=SCHEMA_VERSION_PATTERN)] ResultSchemaVersion = Annotated[str, Field(pattern=RESULT_SCHEMA_VERSION_PATTERN)] # Уверенность распознавания, проценты (число 0–100) Confidence = Annotated[float, Field(ge=0, le=100)] | None # --- общие объекты -------------------------------------------------------------------------------------------------- class ServiceName(StrEnum): TABULAR_PARSER = "tabular-parser" SCAN_GATEWAY = "scan-gateway" class ErrorCode(StrEnum): UNSUPPORTED_FORMAT = "UNSUPPORTED_FORMAT" UNREADABLE = "UNREADABLE" TIMEOUT = "TIMEOUT" SCHEMA_MISMATCH = "SCHEMA_MISMATCH" NOT_IMPLEMENTED = "NOT_IMPLEMENTED" FILE_READ_ERROR = "FILE_READ_ERROR" INVALID_FORMAT = "INVALID_FORMAT" INVALID_DOCUMENT = "INVALID_DOCUMENT" RECOGNITION_FAILED = "RECOGNITION_FAILED" UNKNOWN_ERROR = "UNKNOWN_ERROR" # Строгий режим модели не принимает значение enum строкой: поле разбирает строку само (как JSON Schema `enum`) LaxServiceName = Annotated[ServiceName, Field(strict=False)] LaxErrorCode = Annotated[ErrorCode, Field(strict=False)] class ServiceInfo(ContractModel): """Кто ответил: `service {name, version}`.""" name: LaxServiceName version: str class S3Ref(ContractModel): """Указатель на объект S3: `{bucket, key}`.""" bucket: NonEmptyStr key: NonEmptyStr class Metrics(ContractModel): """Метрики обработки; все поля необязательны.""" duration_ms: OptionalNotNull[IntLike] = None pages: OptionalNotNull[IntLike] = None rows: OptionalNotNull[IntLike] = None fields_total: OptionalNotNull[IntLike] = None fields_low_confidence: OptionalNotNull[IntLike] = None fields_unrecognized: OptionalNotNull[IntLike] = None warnings: OptionalNotNull[list[str]] = None DataType = Literal["BU", "NU", "SUM", "CURRENCY", "QUANTITY"]