diff --git a/Readme.md b/Readme.md index 3292d3e..dabc743 100644 --- a/Readme.md +++ b/Readme.md @@ -27,8 +27,20 @@ PDF-документация и 3D-модели хранятся вне этог - прописывает переменные путей в `Configure Paths`; - добавляет подключение DB Library; - подключает таблицы символов и футпринтов из этого репозитория; +- проверяет и при необходимости предлагает установить PostgreSQL ODBC-драйвер + для DB Library (`psqlodbc` на Arch/CachyOS, `odbc-postgresql` на Ubuntu/Debian); - создаёт резервные копии изменяемых файлов настроек KiCad. +Для полностью неинтерактивного запуска с установкой/регистрацией ODBC-драйвера: + +```bash +./scripts/setup_kicad_user_library.py \ + --pdf-dir "<путь-к-папке-PDF>" \ + --non-interactive \ + --install-odbc-driver \ + --strict-odbc-check +``` + ## Переменные KiCad | Переменная | Назначение | diff --git a/scripts/setup_kicad_user_library.py b/scripts/setup_kicad_user_library.py index 90df3b1..25b0fef 100755 --- a/scripts/setup_kicad_user_library.py +++ b/scripts/setup_kicad_user_library.py @@ -8,12 +8,24 @@ import json import os import re import shutil +import subprocess import sys +import tempfile from datetime import datetime from pathlib import Path ENV_VARS = ("KICAD_LIB_DIR_USER", "KICAD_PDF_DIR_USER", "KICAD_3D_PARTY_USER") +DATABASE_LIBRARY_FILE = "eda-core.kicad_dbl" +DEFAULT_POSTGRES_ODBC_DRIVER = "PostgreSQL Unicode" +DEFAULT_POSTGRES_ODBC_DRIVER_LIBS = ( + Path("/usr/lib/psqlodbcw.so"), + Path("/usr/lib/x86_64-linux-gnu/odbc/psqlodbcw.so"), + Path("/usr/lib/aarch64-linux-gnu/odbc/psqlodbcw.so"), + Path("/usr/lib/arm-linux-gnueabihf/odbc/psqlodbcw.so"), + Path("/usr/lib/odbc/psqlodbcw.so"), +) +AUR_HELPERS = ("paru", "yay", "pikaur") def repo_root() -> Path: @@ -66,6 +78,19 @@ def ask_path(name: str, prompt: str, default: str | None, required: bool = True) print("Path is required.") +def ask_yes_no(prompt: str, default: bool = True) -> bool: + suffix = " [Y/n]" if default else " [y/N]" + while True: + value = input(f"{prompt}{suffix}: ").strip().lower() + if not value: + return default + if value in {"y", "yes", "д", "да"}: + return True + if value in {"n", "no", "н", "нет"}: + return False + print("Please answer yes or no.") + + def backup(path: Path) -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S") target = path.with_name(f"{path.name}.bak-{stamp}") @@ -137,6 +162,316 @@ def configure_library_tables(config_dir: Path) -> None: ) +def warn(message: str) -> None: + print(f"warning: {message}", file=sys.stderr) + + +def extract_odbc_driver(connection_string: str) -> str | None: + match = re.search( + r"(?:^|;)\s*Driver\s*=\s*(?:\{([^}]*)\}|([^;]*))", + connection_string, + flags=re.IGNORECASE, + ) + if not match: + return None + return (match.group(1) or match.group(2) or "").strip() or None + + +def read_database_source(lib_dir: str) -> tuple[Path, dict[str, object]] | None: + dbl_path = Path(lib_dir).expanduser() / DATABASE_LIBRARY_FILE + if not dbl_path.exists(): + warn(f"database library file does not exist: {dbl_path}") + return None + + data = json.loads(dbl_path.read_text(encoding="utf-8")) + source = data.get("source") + if not isinstance(source, dict): + warn(f"database library source block is missing: {dbl_path}") + return None + if source.get("type") != "odbc": + return None + + return dbl_path, source + + +def run_command(args: list[str]) -> subprocess.CompletedProcess[str] | None: + try: + return subprocess.run( + args, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except FileNotFoundError: + return None + + +def run_interactive_command(args: list[str], env: dict[str, str] | None = None) -> bool: + print("+ " + " ".join(args)) + try: + result = subprocess.run(args, check=False, env=env) + except FileNotFoundError: + warn(f"command not found: {args[0]}") + return False + return result.returncode == 0 + + +def with_sudo(args: list[str]) -> list[str]: + if os.geteuid() == 0: + return args + if shutil.which("sudo") is None: + raise SystemExit("sudo is required for system package/ODBC registration changes") + return ["sudo", *args] + + +def read_os_release() -> dict[str, str]: + path = Path("/etc/os-release") + if not path.exists(): + return {} + + data = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + data[key] = value.strip().strip('"') + return data + + +def detect_linux_family() -> str | None: + data = read_os_release() + distro_id = data.get("ID", "").lower() + id_like = set(data.get("ID_LIKE", "").lower().split()) + + if distro_id in {"arch", "cachyos", "manjaro", "endeavouros", "garuda"} or "arch" in id_like: + return "arch" + if distro_id in {"ubuntu", "debian", "linuxmint", "pop"} or id_like & {"debian", "ubuntu"}: + return "debian" + return None + + +def find_aur_helper() -> str | None: + for helper in AUR_HELPERS: + path = shutil.which(helper) + if path: + return path + return None + + +def query_odbc_driver(driver: str) -> tuple[bool, str]: + result = run_command(["odbcinst", "-q", "-d", "-n", driver]) + if result is None: + return False, "" + return result.returncode == 0, result.stdout + result.stderr + + +def parse_driver_library(odbcinst_output: str) -> Path | None: + for line in odbcinst_output.splitlines(): + key, sep, value = line.partition("=") + if sep and key.strip().lower() == "driver" and value.strip(): + return Path(value.strip()) + return None + + +def expected_driver_library(driver: str) -> Path | None: + if driver.lower() == DEFAULT_POSTGRES_ODBC_DRIVER.lower(): + return locate_postgres_odbc_library() or DEFAULT_POSTGRES_ODBC_DRIVER_LIBS[0] + return None + + +def locate_postgres_odbc_library() -> Path | None: + for path in DEFAULT_POSTGRES_ODBC_DRIVER_LIBS: + if path.exists(): + return path + + for base in (Path("/usr/lib"), Path("/usr/local/lib")): + if not base.exists(): + continue + matches = sorted(base.glob("**/psqlodbcw.so")) + if matches: + return matches[0] + return None + + +def print_postgres_odbc_help(driver: str, driver_lib: Path | None) -> None: + if driver.lower() != DEFAULT_POSTGRES_ODBC_DRIVER.lower(): + print("ODBC driver is not ready. Register the driver in /etc/odbcinst.ini.") + return + + lib_path = driver_lib or locate_postgres_odbc_library() or DEFAULT_POSTGRES_ODBC_DRIVER_LIBS[0] + print("PostgreSQL ODBC setup:") + print(" Arch/CachyOS:") + print(" paru -S psqlodbc") + print(" # or: yay -S psqlodbc") + print(" Ubuntu/Debian:") + print(" sudo apt-get install unixodbc odbc-postgresql") + print(" Ensure /etc/odbcinst.ini contains:") + print(f" [{DEFAULT_POSTGRES_ODBC_DRIVER}]") + print(f" Description={DEFAULT_POSTGRES_ODBC_DRIVER}") + print(f" Driver={lib_path}") + print(" UsageCount=1") + print(" Check registration:") + print(f" odbcinst -q -d -n '{DEFAULT_POSTGRES_ODBC_DRIVER}'") + + +def install_postgres_odbc_package(non_interactive: bool) -> bool: + family = detect_linux_family() + + if family == "arch": + if shutil.which("pacman") is not None: + pacman_cmd = ["pacman", "-S", "--needed"] + if non_interactive: + pacman_cmd.append("--noconfirm") + pacman_cmd.append("unixodbc") + if not run_interactive_command(with_sudo(pacman_cmd)): + return False + + helper = find_aur_helper() + if helper is None: + warn("psqlodbc is an AUR package on Arch/CachyOS, but no AUR helper was found") + warn("install it manually, for example: paru -S psqlodbc") + return False + + helper_cmd = [helper, "-S", "--needed"] + if non_interactive: + helper_cmd.append("--noconfirm") + helper_cmd.append("psqlodbc") + return run_interactive_command(helper_cmd) + + if family == "debian": + if shutil.which("apt-get") is None: + warn("apt-get was not found") + return False + env = os.environ.copy() + if non_interactive: + env["DEBIAN_FRONTEND"] = "noninteractive" + return run_interactive_command( + with_sudo(["apt-get", "install", "-y", "unixodbc", "odbc-postgresql"]), + env=env, + ) + + warn("unsupported Linux distribution for automatic psqlODBC installation") + warn("supported families: Arch/CachyOS and Ubuntu/Debian") + return False + + +def register_odbc_driver(driver: str, driver_lib: Path) -> bool: + if shutil.which("odbcinst") is None: + warn("odbcinst was not found after package installation") + return False + if not driver_lib.exists(): + warn(f"cannot register missing driver library: {driver_lib}") + return False + + template = ( + f"[{driver}]\n" + f"Description={driver}\n" + f"Driver={driver_lib}\n" + "UsageCount=1\n" + ) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as tmp: + tmp.write(template) + template_path = Path(tmp.name) + + try: + return run_interactive_command( + with_sudo(["odbcinst", "-i", "-d", "-f", str(template_path)]) + ) + finally: + template_path.unlink(missing_ok=True) + + +def install_and_register_odbc_driver(driver: str, non_interactive: bool) -> bool: + if driver.lower() != DEFAULT_POSTGRES_ODBC_DRIVER.lower(): + warn(f"automatic installation is only implemented for {DEFAULT_POSTGRES_ODBC_DRIVER!r}") + return False + + driver_lib = locate_postgres_odbc_library() + if driver_lib is None or shutil.which("odbcinst") is None: + print("Installing PostgreSQL ODBC driver package...") + if not install_postgres_odbc_package(non_interactive): + return False + driver_lib = locate_postgres_odbc_library() + + if driver_lib is None: + warn("PostgreSQL ODBC package installed, but psqlodbcw.so was not found") + return False + + registered, odbcinst_output = query_odbc_driver(driver) + registered_lib = parse_driver_library(odbcinst_output) if registered else None + if registered and registered_lib == driver_lib and driver_lib.exists(): + return True + + print(f"Registering ODBC driver [{driver}] -> {driver_lib}") + if not register_odbc_driver(driver, driver_lib): + return False + + registered, odbcinst_output = query_odbc_driver(driver) + registered_lib = parse_driver_library(odbcinst_output) if registered else None + return bool(registered and registered_lib and registered_lib.exists()) + + +def check_database_odbc_driver(lib_dir: str) -> bool: + source_info = read_database_source(lib_dir) + if source_info is None: + return True + + dbl_path, source = source_info + dsn = str(source.get("dsn") or "").strip() + connection_string = str(source.get("connection_string") or "") + driver = extract_odbc_driver(connection_string) + + print("Database library ODBC check:") + print(f" file: {dbl_path}") + + if not driver: + if dsn: + warn(f"database library uses DSN '{dsn}', driver lookup is left to unixODBC") + return True + warn("database library ODBC source has no Driver=... or DSN") + return False + + print(f" driver: {driver}") + + if shutil.which("odbcinst") is None: + warn("odbcinst was not found; KiCad cannot resolve ODBC drivers") + print_postgres_odbc_help(driver, expected_driver_library(driver)) + return False + + registered, odbcinst_output = query_odbc_driver(driver) + registered_lib = parse_driver_library(odbcinst_output) if registered else None + driver_lib = registered_lib or expected_driver_library(driver) + + problems = [] + if not registered: + problems.append(f"driver is not registered in /etc/odbcinst.ini as [{driver}]") + if driver_lib is not None and not driver_lib.exists(): + problems.append(f"driver library does not exist: {driver_lib}") + if registered and registered_lib is None: + problems.append(f"registered driver [{driver}] has no Driver=... path") + + if not problems: + if driver_lib is not None: + print(f" library: {driver_lib}") + print(" status: OK") + return True + + for problem in problems: + warn(problem) + print_postgres_odbc_help(driver, driver_lib) + return False + + +def database_odbc_driver_name(lib_dir: str) -> str | None: + source_info = read_database_source(lib_dir) + if source_info is None: + return None + _, source = source_info + connection_string = str(source.get("connection_string") or "") + return extract_odbc_driver(connection_string) + + def main() -> int: parser = argparse.ArgumentParser( description="Configure KiCad paths and library tables for KiCad User Library." @@ -146,7 +481,25 @@ def main() -> int: parser.add_argument("--pdf-dir", help="Path to PDF datasheet directory") parser.add_argument("--3d-dir", dest="models_dir", help="Path to user 3D models directory") parser.add_argument("--non-interactive", action="store_true", help="Do not ask questions") + parser.add_argument("--skip-odbc-check", action="store_true", help="Do not check DB Library ODBC driver") + parser.add_argument( + "--install-odbc-driver", + action="store_true", + help="Install/register missing PostgreSQL ODBC driver for DB Library", + ) + parser.add_argument( + "--no-install-odbc-driver", + action="store_true", + help="Do not offer automatic PostgreSQL ODBC driver installation", + ) + parser.add_argument( + "--strict-odbc-check", + action="store_true", + help="Fail when DB Library ODBC driver is not configured", + ) args = parser.parse_args() + if args.install_odbc_driver and args.no_install_odbc_driver: + raise SystemExit("--install-odbc-driver and --no-install-odbc-driver are mutually exclusive") config_path = detect_kicad_config(args.kicad_version) existing = read_existing_vars(config_path) @@ -176,6 +529,26 @@ def main() -> int: if key != "KICAD_LIB_DIR_USER" and not Path(value).expanduser().exists(): print(f"warning: {key} path does not exist yet: {value}", file=sys.stderr) + odbc_ok = True + if not args.skip_odbc_check: + odbc_ok = check_database_odbc_driver(lib_dir) + if not odbc_ok and not args.no_install_odbc_driver: + should_install = args.install_odbc_driver + if not should_install and not args.non_interactive: + should_install = ask_yes_no("Install/register PostgreSQL ODBC driver now?", True) + + if should_install: + driver = database_odbc_driver_name(lib_dir) + if driver: + odbc_ok = install_and_register_odbc_driver(driver, args.non_interactive) + if odbc_ok: + odbc_ok = check_database_odbc_driver(lib_dir) + else: + warn("could not determine DB Library ODBC driver name") + + if args.strict_odbc_check and not odbc_ok: + raise SystemExit("DB Library ODBC driver is not configured") + backups = [backup(config_path)] config_dir = config_path.parent for table_name in ("sym-lib-table", "fp-lib-table"): @@ -193,6 +566,8 @@ def main() -> int: print("Library table entries updated:") print(" sym-lib-table: eda-core, sym-lib-table") print(" fp-lib-table: fp-lib-table") + if not args.skip_odbc_check and not odbc_ok: + print("Database ODBC check reported warnings above.") print("Backups:") for item in backups: print(f" {item}")