Refs #14 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
234 lines
9.9 KiB
Python
234 lines
9.9 KiB
Python
"""Общие основы моделей контракта 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"]
|