Lib/scripts/setup_kicad_user_library.py
2026-07-02 11:00:44 +03:00

579 lines
20 KiB
Python
Executable file

#!/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())