#!/usr/bin/env python3 """Configure KiCad user paths and library table entries for this library.""" from __future__ import annotations import argparse 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: return Path(__file__).resolve().parents[1] def detect_kicad_config(version: str | None) -> Path: base = Path.home() / ".config" / "kicad" if version: path = base / version / "kicad_common.json" if not path.exists(): raise SystemExit(f"KiCad config not found: {path}") return path candidates = sorted(base.glob("*/kicad_common.json")) if not candidates: raise SystemExit(f"KiCad config not found under {base}") def version_key(path: Path) -> tuple[int, ...]: parts = [] for item in path.parent.name.split("."): try: parts.append(int(item)) except ValueError: parts.append(0) return tuple(parts) return max(candidates, key=version_key) def read_existing_vars(config_path: Path) -> dict[str, str]: data = json.loads(config_path.read_text(encoding="utf-8")) vars_obj = data.get("environment", {}).get("vars") return vars_obj if isinstance(vars_obj, dict) else {} def ask_path(name: str, prompt: str, default: str | None, required: bool = True) -> str: env_value = os.environ.get(name) if env_value: return str(Path(env_value).expanduser()) suffix = f" [{default}]" if default else "" while True: value = input(f"{prompt}{suffix}: ").strip() if not value and default: value = default if value or not required: return str(Path(value).expanduser()) if value else "" 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}") shutil.copy2(path, target) return target def update_common_config(config_path: Path, values: dict[str, str]) -> None: data = json.loads(config_path.read_text(encoding="utf-8")) environment = data.setdefault("environment", {}) vars_obj = environment.get("vars") if not isinstance(vars_obj, dict): vars_obj = {} environment["vars"] = vars_obj vars_obj.update(values) config_path.write_text( json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def ensure_table(path: Path, header: str) -> None: if not path.exists(): path.write_text(f"({header}\n\t(version 7)\n)\n", encoding="utf-8") def upsert_library_entry(table_path: Path, header: str, entry: str) -> None: ensure_table(table_path, header) text = table_path.read_text(encoding="utf-8") name_match = re.search(r'\(name "([^"]+)"\)', entry) if not name_match: raise ValueError(f"Library entry has no name: {entry}") name = re.escape(name_match.group(1)) text = re.sub( rf'\n[^\n]*\(lib\s+\(name "{name}"\)[^\n]*', "", text, ) insert_at = text.rfind("\n)") if insert_at == -1: raise SystemExit(f"Unexpected KiCad table format: {table_path}") text = text[:insert_at] + "\n\t" + entry + text[insert_at:] table_path.write_text(text, encoding="utf-8") def configure_library_tables(config_dir: Path) -> None: sym_table = config_dir / "sym-lib-table" fp_table = config_dir / "fp-lib-table" upsert_library_entry( sym_table, "sym_lib_table", '(lib (name "eda-core") (type "Database") (uri "${KICAD_LIB_DIR_USER}/eda-core.kicad_dbl") (options "") (descr ""))', ) upsert_library_entry( sym_table, "sym_lib_table", '(lib (name "sym-lib-table") (type "Table") (uri "${KICAD_LIB_DIR_USER}/sym-lib-table") (options "") (descr ""))', ) upsert_library_entry( fp_table, "fp_lib_table", '(lib (name "fp-lib-table") (type "Table") (uri "${KICAD_LIB_DIR_USER}/fp-lib-table") (options "") (descr ""))', ) 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." ) parser.add_argument("--kicad-version", help="KiCad config version, for example 10.0") parser.add_argument("--lib-dir", default=str(repo_root()), help="Path to this library repo") 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) lib_dir = str(Path(args.lib_dir).expanduser().resolve()) pdf_dir = args.pdf_dir or existing.get("KICAD_PDF_DIR_USER") models_dir = args.models_dir or existing.get("KICAD_3D_PARTY_USER") if not args.non_interactive: lib_dir = ask_path("KICAD_LIB_DIR_USER", "KiCad library path", lib_dir) pdf_dir = ask_path("KICAD_PDF_DIR_USER", "PDF directory path", pdf_dir) if args.models_dir or existing.get("KICAD_3D_PARTY_USER"): models_dir = ask_path("KICAD_3D_PARTY_USER", "3D models directory path", models_dir) values = { "KICAD_LIB_DIR_USER": lib_dir, "KICAD_PDF_DIR_USER": pdf_dir or "", } if models_dir: values["KICAD_3D_PARTY_USER"] = models_dir if not values["KICAD_PDF_DIR_USER"]: raise SystemExit("Missing required value: KICAD_PDF_DIR_USER") for key, value in values.items(): 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"): table_path = config_dir / table_name if table_path.exists(): backups.append(backup(table_path)) update_common_config(config_path, values) configure_library_tables(config_dir) print(f"Configured KiCad {config_dir.name}:") for key in ENV_VARS: if key in values: print(f" {key} = {values[key]}") 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}") print("Restart KiCad if it was running.") return 0 if __name__ == "__main__": raise SystemExit(main())