126 lines
5.8 KiB
Python
Executable File
126 lines
5.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Помощник sync-statements.sh: проверки пакета arm_statements против хоста (arm-core, tabular-parser, scan-gateway).
|
||
|
||
Команды:
|
||
deps <pyproject.toml> <requirements.txt>
|
||
зависимости пакета (`[project].dependencies`), которых нет в requirements хоста;
|
||
lines <statement_lines.yaml> <statement_code_orders.yaml>
|
||
коды словаря имён строк, которых нет в перечне кодов копии пакета.
|
||
|
||
Коды возврата: 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 <pyproject.toml> <requirements.txt>\n"
|
||
" statements_check.py lines <statement_lines.yaml> <statement_code_orders.yaml>",
|
||
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:]))
|