579 lines
20 KiB
Python
Executable file
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())
|