arm-core/api/tests/contract_identity.py
2026-10-18 13:12:53 +04:00

110 lines
5.7 KiB
Python
Raw Permalink 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.

"""Правило сверки копии контракта `api/contracts/` с `docs/contracts/` на окне перехода v1 → v2 (S-53).
Правило — из спеки docs `recognition-contract`, требование «Копия контракта в сервисе идентична источнику»:
схема копии `<имя>.schema.json` с `$id` `…/v<M>` сверяется с `<имя>.v<M>.schema.json` docs, если такой файл есть,
иначе с основным `<имя>.schema.json`; пример `examples/<имя>.json` — с `examples/v<M>/<имя>.json`, если он есть
(`M` — MAJOR схемы копии вида примера), иначе с `examples/<имя>.json`; прочие файлы копии (`.v<N>`-схемы,
`examples/v<N>/`) — с одноимённым файлом docs. Файл побайтно одинаков в arm-core, tabular-parser и scan-gateway
(`api/tests/contract_identity.py`); правка — во всех трёх, сверка — `shasum -a 256`.
"""
import json
import re
from pathlib import Path
# Порядок значим: «result-message-» начинается с «result-», берётся первый подходящий префикс.
KIND_BY_PREFIX: tuple[tuple[str, str], ...] = (
("request-", "recognition-request"),
("result-message-", "recognition-result-message"),
("result-", "recognition-result"),
)
_EXAMPLE_DEPTH = 2 # examples/<файл>.json
_ID_MAJOR = re.compile(r"/v(\d+)$")
_WINDOW_SCHEMA = re.compile(r"\.v(\d+)\.schema\.json$")
_WINDOW_EXAMPLE = re.compile(r"^examples/v(\d+)/[^/]+\.json$")
def schema_major(schema_path: Path) -> int:
"""MAJOR схемы из её `$id` (`…/v<M>`)."""
schema = json.loads(schema_path.read_text(encoding="utf-8"))
schema_id = schema.get("$id") if isinstance(schema, dict) else None
match = _ID_MAJOR.search(schema_id) if isinstance(schema_id, str) else None
if match is None:
raise ValueError(f"{schema_path.name}: в $id нет MAJOR (…/v<M>)")
return int(match.group(1))
def example_kind(file_name: str) -> str:
"""Имя схемы вида примера по префиксу имени файла."""
for prefix, kind in KIND_BY_PREFIX:
if file_name.startswith(prefix):
return kind
raise ValueError(f"{file_name}: вид примера не определён по префиксу (request-, result-message-, result-)")
def docs_pair(copy_rel: str, copy_dir: Path, docs_dir: Path) -> str:
"""Относительный POSIX-путь файла docs, с которым сверяется файл копии `copy_rel`."""
parts = copy_rel.split("/")
if len(parts) == 1 and copy_rel.endswith(".schema.json") and not _WINDOW_SCHEMA.search(copy_rel):
major = schema_major(copy_dir / copy_rel)
candidate = copy_rel.removesuffix(".schema.json") + f".v{major}.schema.json"
return candidate if (docs_dir / candidate).is_file() else copy_rel
if len(parts) == _EXAMPLE_DEPTH and parts[0] == "examples" and copy_rel.endswith(".json"):
kind = example_kind(parts[1])
kind_schema = copy_dir / f"{kind}.schema.json"
if not kind_schema.is_file():
raise ValueError(f"{copy_rel}: нет схемы {kind}.schema.json в копии")
candidate = f"examples/v{schema_major(kind_schema)}/{parts[1]}"
return candidate if (docs_dir / candidate).is_file() else copy_rel
return copy_rel
def held_window_majors(copy_rels: set[str]) -> set[int]:
"""MAJOR, которые копия держит файлами `.v<N>` (хотя бы один файл — держит все)."""
majors: set[int] = set()
for rel in copy_rels:
match = _WINDOW_SCHEMA.search(rel) if "/" not in rel else _WINDOW_EXAMPLE.match(rel)
if match:
majors.add(int(match.group(1)))
return majors
def _json_files(root: Path) -> set[str]:
return {p.relative_to(root).as_posix() for p in root.rglob("*.json")}
def _is_required_in_copy(docs_rel: str, held: set[int]) -> bool:
parts = docs_rel.split("/")
if len(parts) == 1 and docs_rel.endswith(".schema.json"):
match = _WINDOW_SCHEMA.search(docs_rel)
return match is None or int(match.group(1)) in held
if len(parts) == _EXAMPLE_DEPTH and parts[0] == "examples":
return True
match = _WINDOW_EXAMPLE.match(docs_rel)
return match is not None and int(match.group(1)) in held
def identity_problems(copy_dir: Path, docs_dir: Path) -> list[str]:
"""Все расхождения копии с docs; каждое начинается с относительного пути файла. Пустой список — идентична."""
copy_files = _json_files(copy_dir)
docs_files = _json_files(docs_dir)
problems: list[str] = []
for copy_rel in sorted(copy_files):
try:
pair = docs_pair(copy_rel, copy_dir, docs_dir)
except ValueError as exc:
problems.append(f"{copy_rel}: {exc}")
continue
if pair not in docs_files:
problems.append(f"{copy_rel}: нет пары в docs/contracts ({pair})")
elif (copy_dir / copy_rel).read_bytes() != (docs_dir / pair).read_bytes():
problems.append(
f"{copy_rel}: отличается от docs/contracts/{pair} — скопируйте заново (cp), не правьте руками"
)
held = held_window_majors(copy_files)
for docs_rel in sorted(docs_files - copy_files):
if _is_required_in_copy(docs_rel, held):
problems.append(f"{docs_rel}: есть в docs/contracts, нет в api/contracts")
return sorted(problems, key=lambda p: p.split(":", 1)[0])