Files
mihailkudravcev 91521e6604 Release 0.3.4 with external processing indexing.
Add virtual baseconf support for external data processors so reindex can discover and index them alongside regular src-based configurations, and update docs to use generic examples without workspace-specific names.
2026-07-21 13:03:25 +03:00

377 lines
13 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.
"""Общие аргументы 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С: <name>/src/ или virtual <dir>.<Обработка>. "
"Можно несколько раз или через запятую: -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-выгрузка или virtual внешняя обработка, наличие в индексе.
Возвращает канонические имена. Иначе BaseconfError с подсказками.
"""
if not names:
return []
resolved = resolve_baseconf_names(names)
available = {c.name for c in discover_configs(project_root, None)}
missing_src: list[str] = []
for name in resolved:
if discover_configs(project_root, [name]):
continue
if name not in available:
missing_src.append(name)
if missing_src:
available_list = sorted(available)
lines = [
"Источник индексации 1С не найден в репозитории "
"(нет <name>/src/ или внешней обработки <dir>/<name>.xml + <dir>/<name>/):",
*(f" - {n}" for n in missing_src),
"",
"Доступные выгрузки в проекте:",
]
if available_list:
lines.extend(f" - {n}" for n in available_list)
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 <имя-выгрузки>"
)