#!/usr/bin/env python3 """Помощник sync-statements.sh: проверки пакета arm_statements против хоста (arm-core, tabular-parser, scan-gateway). Команды: deps зависимости пакета (`[project].dependencies`), которых нет в requirements хоста; lines коды словаря имён строк, которых нет в перечне кодов копии пакета. Коды возврата: 0 — находок нет, 20 — есть находки, 1 — файл не читается или не той формы, 2 — неверные аргументы. Спека: openspec statement-normalization (единая нормализация ББ/ОФР, issue #116). Только stdlib и PyYAML. """ from __future__ import annotations import re import sys import tomllib import yaml _NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*") def normalize_name(name: str) -> str: """Имя пакета по PEP 503.""" return re.sub(r"[-_.]+", "-", name).lower() def requirement_name(line: str) -> str | None: """Имя пакета из строки требования; пустая строка, комментарий и опции (`-r`, `--index-url`) дают None.""" line = line.strip() if not line or line.startswith("#") or line.startswith("-"): return None match = _NAME_RE.match(line) return match.group(0) if match else None def _pinned_version(line: str) -> str | None: match = re.search(r"==\s*([^\s;,#]+)", line) return match.group(1) if match else None def missing_dependencies(pyproject_text: str, requirements_text: str) -> tuple[list[str], list[str]]: """(зависимости пакета, которых нет среди requirements; примечания о различии версий).""" project = tomllib.loads(pyproject_text).get("project") deps = project.get("dependencies") if isinstance(project, dict) else None if not deps: return [], [] host: dict[str, str] = {} for line in requirements_text.splitlines(): name = requirement_name(line) if name: host[normalize_name(name)] = line.split("#", 1)[0].strip() missing: list[str] = [] notes: list[str] = [] for dep in deps: name = requirement_name(dep) if not name: continue host_line = host.get(normalize_name(name)) if host_line is None: missing.append(dep.strip()) continue pkg_version, host_version = _pinned_version(dep), _pinned_version(host_line) if pkg_version and host_version and pkg_version != host_version: notes.append(f"версии различаются: пакет {dep.strip()}, хост {host_line}") return missing, notes def codes_outside_orders(lines_doc: object, orders_doc: object) -> list[str]: """Коды словаря имён, которых нет в объединении вариантов full и simplified перечня.""" if not isinstance(lines_doc, dict) or not isinstance(lines_doc.get("editions"), list): raise ValueError("словарь имён: нет списка editions") if not isinstance(orders_doc, dict) or not isinstance(orders_doc.get("editions"), dict): raise ValueError("перечень кодов: нет словаря editions") problems: list[str] = [] for edition in lines_doc["editions"]: if not isinstance(edition, dict) or "id" not in edition or not isinstance(edition.get("forms"), dict): raise ValueError("словарь имён: запись редакции без id или forms") ed_id = str(edition["id"]) ed_orders = orders_doc["editions"].get(ed_id) for form, entries in edition["forms"].items(): allowed: set[str] = set() if isinstance(ed_orders, dict) and isinstance(ed_orders.get(form), dict): for variant in ("full", "simplified"): allowed.update(str(c) for c in ed_orders[form].get(variant) or []) for entry in entries or []: code = str(entry["code"]) if isinstance(entry, dict) and "code" in entry else None if code is None: raise ValueError(f"словарь имён: запись без code в редакции {ed_id}, форма {form}") if code not in allowed: problems.append(f"код {code} формы {form} редакции {ed_id} нет в перечне копии пакета") return problems def _read(path: str) -> str: with open(path, encoding="utf-8") as fh: return fh.read() def main(argv: list[str]) -> int: if len(argv) != 3 or argv[0] not in ("deps", "lines"): print("использование: statements_check.py deps \n" " statements_check.py lines ", file=sys.stderr) return 2 try: if argv[0] == "deps": missing, notes = missing_dependencies(_read(argv[1]), _read(argv[2])) findings = [f"зависимость пакета {dep} не найдена в {argv[2]}" for dep in missing] else: findings = codes_outside_orders(yaml.safe_load(_read(argv[1])), yaml.safe_load(_read(argv[2]))) notes = [] except (OSError, ValueError, yaml.YAMLError, tomllib.TOMLDecodeError) as exc: print(f"ошибка: {exc}", file=sys.stderr) return 1 for finding in findings: print(finding) for note in notes: print(f"примечание: {note}") return 20 if findings else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))