"""Общие аргументы CLI: --config (скрипт), --root, --baseconf (конфигурация 1С).""" from __future__ import annotations import argparse 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 (YAML; legacy JSON поддерживается).""" ext = path.suffix.lower() try: raw = path.read_text(encoding="utf-8") except OSError as e: raise BaseconfError(f"Не удалось прочитать --config {path}: {e}", exit_code=1) from e if ext == ".json": try: import json data = json.loads(raw) except Exception 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 try: import yaml # type: ignore except Exception as e: raise BaseconfError( "Для чтения --config в формате YAML нужен пакет pyyaml: pip install pyyaml", exit_code=1, ) from e try: data = yaml.safe_load(raw) except Exception as e: raise BaseconfError(f"Некорректный YAML в --config {path}: {e}", exit_code=1) from e if not isinstance(data, dict): raise BaseconfError(f"--config {path}: ожидается YAML-объект", exit_code=1) return data def discover_local_tool_config() -> Path | None: """Автопоиск локального конфига без --config.""" candidates = ("config.local.yml", "config.local.yaml", "config.local.json") roots = [Path.cwd(), project_root_from_here()] seen: set[Path] = set() for root in roots: for rel in (Path("tools/index"), Path(".")): base = (root / rel).resolve() if base in seen or not base.is_dir(): continue seen.add(base) for name in candidates: p = base / name if p.is_file(): return p return None def apply_tool_config(data: dict[str, Any]) -> None: """Применить настройки из YAML (--config): алиасы и дефолтные baseconf.""" from .config import set_config_aliases, set_default_baseconfs aliases = data.get("baseconf_aliases") if aliases is not None: if not isinstance(aliases, dict): raise BaseconfError("--config: baseconf_aliases должен быть объектом YAML") set_config_aliases({str(k): str(v) for k, v in aliases.items()}) default_baseconfs = data.get("default_baseconfs") if default_baseconfs is not None: if not isinstance(default_baseconfs, list): raise BaseconfError("--config: default_baseconfs должен быть YAML-списком") set_default_baseconfs([str(x) for x in default_baseconfs]) else: set_default_baseconfs([]) def resolve_project_root(args: argparse.Namespace) -> Path: """Корень проекта: --root → --config/auto-config → авто.""" if getattr(args, "root", None): return Path(args.root).resolve() cfg_path = getattr(args, "config", None) if not cfg_path: auto = discover_local_tool_config() if auto is not None: cfg_path = str(auto) setattr(args, "config", cfg_path) if cfg_path: data = load_tool_config(Path(cfg_path)) apply_tool_config(data) 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=( "YAML с настройками утилиты (project_root и др.), см. config.example.yml. " "Без параметра: автопоиск config.local.yml/.yaml/.json" ), ) parser.add_argument( "--root", "-r", metavar="DIR", help="Корень проекта с выгрузками 1С (перекрывает project_root из --config)", ) def add_baseconf_args( parser: argparse.ArgumentParser, *, mode: str = "query", ) -> None: """Фильтр по выгрузке конфигурации 1С в репозитории.""" if mode == "index": default_hint = ( "Без параметра — default_baseconfs из --config; " "если блок не задан, индексировать все выгрузки с src/." ) else: default_hint = "Без параметра — поиск по всем проиндексированным." parser.add_argument( "--baseconf", "-b", action="append", metavar="NAME", dest="baseconf", help=( "Конфигурация 1С в репозитории (папка /src/). " "Можно несколько раз или через запятую: -b cfg1,cfg2. " "Краткие имена (алиасы) — в baseconf_aliases файла --config. " 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( [ "", "Краткие имена (алиасы) для -b задаются в baseconf_aliases файла --config.", ] ) 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 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 index_1c.py reindex --full -j 12\n" "или для одной конфигурации:\n" " python index_1c.py reindex --full -b <имя-выгрузки>" )