#!/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 ''}: {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())