arm-ka-docs/scripts/validate_contracts.py

202 lines
8.9 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.

#!/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())