Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
202 lines
8.9 KiB
Python
202 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12 и упорядоченных перечней кодов форм.
|
||
|
||
Запуск из любого cwd: python scripts/validate_contracts.py
|
||
Зависимости: стандартная библиотека + jsonschema + PyYAML (`make venv`).
|
||
Код возврата: 0 — всё валидно, 1 — любая ошибка.
|
||
|
||
Окно перехода на MAJOR 2 (change unified-statement-normalization): схемы прежнего MAJOR лежат рядом как
|
||
`*.v1.schema.json`, их примеры — в `examples/v1/`; удаляются после перехода обоих сервисов (маппер 1.x ядра — тоже).
|
||
"""
|
||
import datetime
|
||
import json
|
||
import re
|
||
import sys
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
|
||
from jsonschema import Draft202012Validator, FormatChecker
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
CONTRACTS = ROOT / "contracts"
|
||
EXAMPLES = CONTRACTS / "examples"
|
||
|
||
SCHEMAS = {
|
||
"request": CONTRACTS / "recognition-request.schema.json",
|
||
"message": CONTRACTS / "recognition-result-message.schema.json",
|
||
"result": CONTRACTS / "recognition-result.schema.json",
|
||
"message.v1": CONTRACTS / "recognition-result-message.v1.schema.json",
|
||
"result.v1": CONTRACTS / "recognition-result.v1.schema.json",
|
||
}
|
||
|
||
# Каталог примеров → суффикс вида схемы: examples/ — текущий MAJOR, examples/v1/ — прежний (окно перехода).
|
||
EXAMPLE_DIRS = [(EXAMPLES, ""), (EXAMPLES / "v1", ".v1")]
|
||
|
||
CODE_ORDERS = CONTRACTS / "statement-code-orders.yaml"
|
||
CODE_RE = re.compile(r"^\d{4}$") # в перечне только четырёхзначные коды (пятизначные — через родителя)
|
||
|
||
# Порядок важен: result-message-* проверяется раньше result-*.
|
||
PATTERNS = [
|
||
("request-", "request"),
|
||
("result-message-", "message"),
|
||
("result-", "result"),
|
||
]
|
||
|
||
_DATE_TIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$")
|
||
|
||
|
||
def _check_date_time(value):
|
||
# Собственная проверка вместо rfc3339-validator: нужна и дата, и время, и часовой пояс.
|
||
if not isinstance(value, str):
|
||
return True
|
||
if not _DATE_TIME_RE.match(value):
|
||
return False
|
||
datetime.datetime.fromisoformat(value.replace("z", "Z")) # ValueError -> формат не прошёл
|
||
return True
|
||
|
||
|
||
def make_format_checker():
|
||
checker = FormatChecker()
|
||
checker.checks("date-time", raises=ValueError)(_check_date_time)
|
||
return checker
|
||
|
||
|
||
def short_path(error):
|
||
path = "/".join(str(p) for p in error.absolute_path)
|
||
return f"{path or '<root>'}: {error.message[:120]}"
|
||
|
||
|
||
def main():
|
||
failed = False
|
||
validators = {}
|
||
format_checker = make_format_checker()
|
||
|
||
for kind, path in SCHEMAS.items():
|
||
if not path.is_file():
|
||
print(f"FAIL схема {path.name}: файл не найден")
|
||
failed = True
|
||
continue
|
||
try:
|
||
schema = json.loads(path.read_text(encoding="utf-8"))
|
||
Draft202012Validator.check_schema(schema)
|
||
except Exception as exc: # битый JSON или нарушение метасхемы
|
||
print(f"FAIL схема {path.name}: {str(exc).splitlines()[0][:150]}")
|
||
failed = True
|
||
continue
|
||
validators[kind] = Draft202012Validator(schema, format_checker=format_checker)
|
||
print(f"OK схема {path.name}")
|
||
|
||
counts = {kind: 0 for kind in SCHEMAS}
|
||
examples = [(example, suffix) for folder, suffix in EXAMPLE_DIRS for example in sorted(folder.glob("*.json"))]
|
||
for example, suffix in examples:
|
||
base = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None)
|
||
kind = None if base is None else (base if base == "request" else base + suffix)
|
||
if kind is None or kind not in SCHEMAS:
|
||
print(f"FAIL {example.name}: имя не подходит ни под один шаблон (request-*, result-message-*, result-*)")
|
||
failed = True
|
||
continue
|
||
counts[kind] += 1
|
||
validator = validators.get(kind)
|
||
if validator is None:
|
||
print(f"FAIL {example.name} [{SCHEMAS[kind].name}]: схема недоступна")
|
||
failed = True
|
||
continue
|
||
try:
|
||
instance = json.loads(example.read_text(encoding="utf-8"))
|
||
except ValueError as exc:
|
||
print(f"FAIL {example.name} [{SCHEMAS[kind].name}]: не JSON: {exc}")
|
||
failed = True
|
||
continue
|
||
errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path))
|
||
if errors:
|
||
failed = True
|
||
print(f"FAIL {example.name} [{SCHEMAS[kind].name}]")
|
||
for error in errors:
|
||
print(f" {short_path(error)}")
|
||
else:
|
||
print(f"OK {example.name} [{SCHEMAS[kind].name}]")
|
||
|
||
for kind, count in counts.items():
|
||
if count == 0:
|
||
print(f"FAIL нет ни одного примера для схемы {SCHEMAS[kind].name}")
|
||
failed = True
|
||
|
||
failed = check_code_orders() or failed
|
||
total = sum(counts.values())
|
||
print(f"Итого: примеров {total}, {'есть ошибки' if failed else 'всё валидно'}")
|
||
return 1 if failed else 0
|
||
|
||
|
||
def code_order_errors(data):
|
||
"""Структура перечня: editions[id] → forms[BALANCE|INCOME] → variants[full|simplified] → список кодов."""
|
||
errors = []
|
||
if not isinstance(data, dict) or not isinstance(data.get("editions"), dict):
|
||
return ["нет словаря editions"]
|
||
for edition in ("66n", "fsbu4"):
|
||
forms = data["editions"].get(edition)
|
||
if not isinstance(forms, dict):
|
||
errors.append(f"нет редакции {edition}")
|
||
continue
|
||
for form, first in (("BALANCE", "1"), ("INCOME", "2")):
|
||
variants = forms.get(form)
|
||
if not isinstance(variants, dict):
|
||
errors.append(f"{edition}: нет формы {form}")
|
||
continue
|
||
for variant in ("full", "simplified"):
|
||
codes = variants.get(variant)
|
||
where = f"{edition}/{form}/{variant}"
|
||
if not isinstance(codes, list) or not codes:
|
||
errors.append(f"{where}: пустой или не список")
|
||
continue
|
||
for code in codes:
|
||
if not isinstance(code, str) or not CODE_RE.fullmatch(code) or not code.startswith(first):
|
||
errors.append(f"{where}: код {code!r} — не строка из 4 цифр формы")
|
||
errors.extend(repeat_errors(where, codes, data.get("repeats", {}).get(edition, {}).get(form, []), variant))
|
||
return errors
|
||
|
||
|
||
def repeat_errors(where, codes, repeats, variant):
|
||
"""Законные повторы (Р-20): в варианте full код встречается ровно 1 + (число его вхождений в repeats) раз;
|
||
в simplified повторы допустимы только из repeats и не чаще, чем в full; код из repeats, которого нет в full, — ошибка."""
|
||
errors = []
|
||
counts = Counter(c for c in codes if isinstance(c, str))
|
||
allowed = Counter(repeats)
|
||
for code in sorted(set(counts) | set(allowed)):
|
||
expected = 1 + allowed[code]
|
||
actual = counts[code]
|
||
if variant == "full":
|
||
if actual != expected:
|
||
errors.append(f"{where}: код {code} встречается {actual} раз, а по repeats должен {expected}")
|
||
elif actual > 1 and actual > expected:
|
||
errors.append(f"{where}: повтор кода {code} ({actual} раз) вне перечня repeats")
|
||
return errors
|
||
|
||
|
||
def check_code_orders():
|
||
"""Упорядоченные перечни кодов форм (05-domain-rules.md §11.9): yaml разбирается и устроен как описано."""
|
||
if not CODE_ORDERS.is_file():
|
||
print(f"FAIL {CODE_ORDERS.name}: файл не найден")
|
||
return True
|
||
try:
|
||
import yaml
|
||
except ImportError:
|
||
print(f"FAIL {CODE_ORDERS.name}: нужен PyYAML (make venv)")
|
||
return True
|
||
try:
|
||
data = yaml.safe_load(CODE_ORDERS.read_text(encoding="utf-8"))
|
||
except yaml.YAMLError as exc:
|
||
print(f"FAIL {CODE_ORDERS.name}: не YAML: {str(exc).splitlines()[0]}")
|
||
return True
|
||
errors = code_order_errors(data)
|
||
if errors:
|
||
print(f"FAIL {CODE_ORDERS.name}")
|
||
for error in errors:
|
||
print(f" {error}")
|
||
return True
|
||
print(f"OK {CODE_ORDERS.name}")
|
||
return False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|