"""Общие аргументы CLI: --config (скрипт), --root, --baseconf (конфигурация 1С).""" from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any from . import db as dbmod from .config import cache_dir, discover_configs, project_root_from_here, resolve_config_name class BaseconfError(Exception): """Ошибка выбора конфигурации 1С (--baseconf).""" def __init__(self, message: str, *, exit_code: int = 2): super().__init__(message) self.exit_code = exit_code def load_tool_config(path: Path) -> dict[str, Any]: """Файл настроек утилиты index (JSON). Не путать с конфигурацией 1С.""" try: data = json.loads(path.read_text(encoding="utf-8")) except OSError as e: raise BaseconfError(f"Не удалось прочитать --config {path}: {e}", exit_code=1) from e except json.JSONDecodeError as e: raise BaseconfError(f"Некорректный JSON в --config {path}: {e}", exit_code=1) from e if not isinstance(data, dict): raise BaseconfError(f"--config {path}: ожидается JSON-объект", exit_code=1) return data def resolve_project_root(args: argparse.Namespace) -> Path: """Корень проекта: --root → project_root из --config → авто.""" if getattr(args, "root", None): return Path(args.root).resolve() cfg_path = getattr(args, "config", None) if cfg_path: data = load_tool_config(Path(cfg_path)) pr = data.get("project_root") if pr: return Path(pr).resolve() return project_root_from_here() def add_tool_args(parser: argparse.ArgumentParser) -> None: """Параметры утилиты (не конфигурация 1С).""" parser.add_argument( "--config", "-c", metavar="FILE", help="JSON с настройками утилиты (project_root и др.), см. config.example.json", ) parser.add_argument( "--root", "-r", metavar="DIR", help="Корень проекта CRM3-26 (перекрывает project_root из --config)", ) def add_baseconf_args( parser: argparse.ArgumentParser, *, mode: str = "query", ) -> None: """Фильтр по выгрузке конфигурации 1С в репозитории.""" if mode == "index": default_hint = "Без параметра — индексировать все выгрузки с src/ в репозитории." else: default_hint = "Без параметра — поиск по всем проиндексированным." parser.add_argument( "--baseconf", "-b", action="append", metavar="NAME", dest="baseconf", help=( "Конфигурация 1С в репозитории (папка /src/). " "Можно несколько раз или через запятую: -b target,source. " "Алиасы: target→crm3-26, source→crm3-dev. " f"{default_hint}" ), ) def make_parent_parser(*, include_baseconf: bool = True, baseconf_mode: str = "query") -> argparse.ArgumentParser: """Общие опции для подкоманд (--config, --root, опционально --baseconf).""" p = argparse.ArgumentParser(add_help=False) add_tool_args(p) if include_baseconf: add_baseconf_args(p, mode=baseconf_mode) return p def extract_baseconf_argv(argv: list[str]) -> tuple[list[str], list[str]]: """ Извлечь все -b / --baseconf из argv (до или после подкоманды). Возвращает (значения baseconf, argv без этих аргументов). """ baseconfs: list[str] = [] rest: list[str] = [] i = 0 while i < len(argv): tok = argv[i] if tok in ("-b", "--baseconf"): if i + 1 >= len(argv): rest.append(tok) break baseconfs.append(argv[i + 1]) i += 2 continue if tok.startswith("--baseconf="): baseconfs.append(tok.split("=", 1)[1]) i += 1 continue if tok.startswith("-b") and len(tok) > 2: baseconfs.append(tok[2:]) i += 1 continue rest.append(tok) i += 1 return baseconfs, rest def merge_baseconf_args(args: argparse.Namespace, extra: list[str] | None) -> None: """Объединить baseconf из argv и argparse (in-place).""" if not extra: return current = list(getattr(args, "baseconf", None) or []) seen = set(current) for item in extra: if item not in seen: seen.add(item) current.append(item) args.baseconf = current or None def resolve_baseconf_names(raw: list[str] | None) -> list[str]: """Разрешить алиасы имён конфигураций 1С (без проверки индекса).""" if not raw: return [] out: list[str] = [] seen: set[str] = set() for item in raw: for part in item.replace(";", ",").split(","): part = part.strip() if not part: continue name = resolve_config_name(part) if name not in seen: seen.add(name) out.append(name) return out def validate_baseconfs( project_root: Path, names: list[str], *, conn=None, require_indexed: bool = True, ) -> list[str]: """ Проверить --baseconf: папка src/, наличие в индексе. Возвращает канонические имена. Иначе BaseconfError с подсказками. """ if not names: return [] resolved = resolve_baseconf_names(names) missing_src: list[str] = [] for name in resolved: if not (project_root / name / "src").is_dir(): missing_src.append(name) if missing_src: available = [c.name for c in discover_configs(project_root, None)] lines = [ "Конфигурация 1С не найдена в репозитории (нет каталога /src/):", *(f" - {n}" for n in missing_src), "", "Доступные выгрузки в проекте:", ] if available: lines.extend(f" - {n}" for n in available) else: lines.append(" (нет)") lines.extend( [ "", "Алиасы: target→crm3-26, source→crm3-dev, crm3_old→crm3-dev", ] ) raise BaseconfError("\n".join(lines)) if not require_indexed or conn is None: return resolved indexed = {c["name"] for c in dbmod.list_configs(conn)} not_indexed = [n for n in resolved if n not in indexed] if not_indexed: lines = [ "Конфигурация 1С есть в репозитории, но отсутствует в индексе cache/1c_meta/index.sqlite:", *(f" - {n}" for n in not_indexed), "", "Проиндексировано:", ] if indexed: lines.extend(f" - {n}" for n in sorted(indexed)) else: lines.append(" (индекс пуст)") lines.extend( [ "", f"Переиндексация: python tools/index/index_1c.py reindex --full -b {not_indexed[0]}", ] ) raise BaseconfError("\n".join(lines)) return resolved def get_baseconfs_for_query( args: argparse.Namespace, conn, project_root: Path, ) -> list[str] | None: """None = все конфигурации в индексе; иначе список имён.""" raw = getattr(args, "baseconf", None) if not raw: return None return validate_baseconfs(project_root, raw, conn=conn, require_indexed=True) def format_not_found_object( name: str, *, baseconfs: list[str] | None, conn, ) -> str: """Подсказка, если объект не найден.""" lines = [f"Объект «{name}» не найден."] if baseconfs: lines.append(f"Ограничение --baseconf: {', '.join(baseconfs)}") # похожие имена like = f"%{name}%" params: list[Any] = [like, like, like] sql = """ SELECT config, full_name, synonym FROM objects WHERE name LIKE ? OR full_name LIKE ? OR synonym LIKE ? """ if baseconfs: sql += f" AND config IN ({','.join('?' * len(baseconfs))})" params.extend(baseconfs) sql += " ORDER BY config, full_name LIMIT 15" rows = conn.execute(sql, params).fetchall() if rows: lines.append("") lines.append("Возможно, имелось в виду:") for r in rows: syn = f" — {r['synonym']}" if r["synonym"] else "" lines.append(f" [{r['config']}] {r['full_name']}{syn}") else: indexed = [c["name"] for c in dbmod.list_configs(conn)] lines.append("") lines.append("В индексе конфигурации: " + (", ".join(indexed) if indexed else "(пусто)")) lines.append("Проверьте имя (Document.ЗаказКлиента) или выполните: query_1c.py search \"…\"") return "\n".join(lines) def format_not_found_modules( query: str, *, baseconfs: list[str] | None, conn, ) -> str: lines = [f"Модули по запросу «{query}» не найдены."] if baseconfs: lines.append(f"Ограничение --baseconf: {', '.join(baseconfs)}") indexed = [c["name"] for c in dbmod.list_configs(conn)] lines.append("Проиндексировано: " + ", ".join(indexed) if indexed else "Индекс пуст.") return "\n".join(lines) def handle_baseconf_error(e: BaseconfError) -> int: print(str(e), file=sys.stderr) return e.exit_code def index_db_missing_message(project_root: Path) -> str: path = dbmod.global_db_path(cache_dir(project_root)) return ( f"Индекс не найден: {path}\n" "Сначала выполните:\n" " python tools/index/index_1c.py reindex --full -j 12\n" "или для одной конфигурации:\n" " python tools/index/index_1c.py reindex --full -b crm3-26" )