From 4b8007ae746189b93fa9ed36e5870e8b444206e8 Mon Sep 17 00:00:00 2001 From: nandan440 Date: Sat, 16 May 2026 15:51:18 +0530 Subject: [PATCH] Added python tool manager --- .DS_Store | Bin 16388 -> 18436 bytes python-tool-manager-gui/README.md | 44 ++ python-tool-manager-gui/__init__.py | 0 python-tool-manager-gui/dependency.py | 210 +++++++ python-tool-manager-gui/ghdl.py | 294 +++++++++ python-tool-manager-gui/gui.py | 341 ++++++++++ python-tool-manager-gui/install_details.yml | 0 python-tool-manager-gui/installer.py | 180 ++++++ python-tool-manager-gui/kicad.py | 663 ++++++++++++++++++++ python-tool-manager-gui/llmv.py | 257 ++++++++ python-tool-manager-gui/ngspice.py | 341 ++++++++++ python-tool-manager-gui/python.py | 443 +++++++++++++ python-tool-manager-gui/requirements.txt | 6 + python-tool-manager-gui/tools.yml | 183 ++++++ python-tool-manager-gui/verilator.py | 321 ++++++++++ python-tool-manager-gui/version.py | 114 ++++ 16 files changed, 3397 insertions(+) create mode 100644 python-tool-manager-gui/README.md create mode 100644 python-tool-manager-gui/__init__.py create mode 100644 python-tool-manager-gui/dependency.py create mode 100644 python-tool-manager-gui/ghdl.py create mode 100644 python-tool-manager-gui/gui.py create mode 100644 python-tool-manager-gui/install_details.yml create mode 100644 python-tool-manager-gui/installer.py create mode 100644 python-tool-manager-gui/kicad.py create mode 100644 python-tool-manager-gui/llmv.py create mode 100644 python-tool-manager-gui/ngspice.py create mode 100644 python-tool-manager-gui/python.py create mode 100644 python-tool-manager-gui/requirements.txt create mode 100644 python-tool-manager-gui/tools.yml create mode 100644 python-tool-manager-gui/verilator.py create mode 100644 python-tool-manager-gui/version.py diff --git a/.DS_Store b/.DS_Store index 3e3c30e1b4b742dc0b5e2c6e77d7e4bb8755e52c..715c914a60cd20e49d0da10825bb98f15beaa1e3 100644 GIT binary patch delta 2031 zcmb7FO>o;p6n-l^kzJcw`6I1EhfsGWKvIuO0;e(wE*@fVn@p2334g(sWk(ZPYfEyP zbQqLF;lzQ~XL{kp(dgII9H8am0hc~u#m7BdVQ@#x>*DY;d*KTH(|MK^QGAr*L3orKsg)%(>^)PuX z^=Fp(K|LaJvD`Gdsl_uZ>%C8Ll;k|UMOW#4+M`=^hrXfj=sx{SztLm*o5@UJ=h-}4 zVsEl#w#tg^1E#WFrZcN&Kdgc6-OaPPgUOv z(Y2t0PfuS#oDl9fNR6TWoA~MeVSs}ms3UTu>`;zT@WHA2FC20U;A9_XQtfF>hy*F? z4w+5S6%jCPBKRVl-hkw%@U(pvXHvjf3ITCA-0PoX29j!nSrumAZ~#7C z1FV;vU69>4;HKCIM4m-)*x_3CNg6020~v%~LuG8Dg-TQkM(vXlL>urhxO_rrOI$Yt zxcBV3KRlhkcx*tt=}if1K(ze4!3?Y91mbW)d#vjT$35Lfc5 hka6Ppha%=r#k#|jUwA(*ekFB&NiY1T`_7~E;y+Hu1+V}B delta 276 zcmZpfz}V8jsKUU&!01$(V#vT?0c3IjF^Cr6W?(P?Vg()s1_|zoD%zqVKv5Q;1Or0` zLkdGqPH~xYQqE>U4l$O^3LL%6oQyzuAmHGJh^g^BWuAPWO@8xs5fS!@C1)8~HXk(k z!nFCZxfRRgD$NT_44j+yDeACquF!O6*(}U*hG{atfgCq0Lmopq#D>WSMUGD_IWyV7 zg@u&~Xfn&@5W^zo&5|~!nI<1J)1Lfa=fLC&&GWKsV0#%XfUae5MRz&SF|U~?^SgLX b_UGZ?0lE?7K`@w{&eOYjf^7&Rl3`~6UlBk2 diff --git a/python-tool-manager-gui/README.md b/python-tool-manager-gui/README.md new file mode 100644 index 000000000..7798304d3 --- /dev/null +++ b/python-tool-manager-gui/README.md @@ -0,0 +1,44 @@ +

eSim Tool Manager GUI

+ +

+This project provides a GUI-based tool manager for eSim. +It helps install, update, and manage different tools easily. +

+ +

Features

+
    +
  • Load tools dynamically from YAML
  • +
  • Install, update, and uninstall tools
  • +
  • Supports tools like KiCad, Ngspice, GHDL, LLVM, Verilator, Python
  • +
  • Runs tasks in background (UI does not freeze)
  • +
  • Shows logs in real time
  • +
  • Checks installed tools and versions
  • +
  • Basic error handling
  • +
+ +

How to run

+
+pip install -r requirements.txt
+python gui.py
+
+ +

Project files

+
    +
  • gui.py → main GUI
  • +
  • installer.py → installation logic
  • +
  • kicad.py, ngspice.py, etc. → tool modules
  • +
  • tools.yml → configuration
  • +
  • install_details.yml → install info
  • +
+ +

Notes

+
    +
  • Works on Linux, macOS, and Windows
  • +
  • Uses system package managers
  • +
  • .gitignore excludes unnecessary files
  • +
+ +

Contribution

+

+You can fork the repo and create a pull request. +

\ No newline at end of file diff --git a/python-tool-manager-gui/__init__.py b/python-tool-manager-gui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python-tool-manager-gui/dependency.py b/python-tool-manager-gui/dependency.py new file mode 100644 index 000000000..634935e70 --- /dev/null +++ b/python-tool-manager-gui/dependency.py @@ -0,0 +1,210 @@ +import yaml +import shutil +import os + +try: + from tool_manager_gui.version import get_version, compare_versions, max_version +except ImportError: + from version import get_version, compare_versions, max_version + + +# ---------- LOAD TOOLS ---------- +def load_tools(): + base_dir = os.path.dirname(__file__) + tools_path = os.path.join(base_dir, "tools.yml") + + with open(tools_path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + return data.get("tools", {}) + + +# ---------- CHECK HELPERS ---------- +def check_executable(names): + if isinstance(names, str): + names = [names] + return any(shutil.which(name) for name in names) + + +def check_directory(path): + return os.path.exists(path) + +def is_installed(info): + ttype = (info or {}).get("type") + if ttype == "executable": + return check_executable((info or {}).get("check")) + if ttype == "directory": + return check_directory((info or {}).get("path")) + return False + + +def get_installed_version(info): + """ + Best-effort: only meaningful for executable tools. + Returns version string or None. + """ + if (info or {}).get("type") != "executable": + return None + if not check_executable((info or {}).get("check")): + return None + cmd = (info or {}).get("version_cmd") + return get_version(cmd) + + +def get_latest_target_version(info): + """ + Defines what "latest" means for update checks. + Priority: + - recommended_version + - highest item in versions list + - min_version (as a fallback target) + """ + if not isinstance(info, dict): + return None + rec = info.get("recommended_version") + if isinstance(rec, str) and rec.strip(): + return rec.strip() + versions = info.get("versions") + if isinstance(versions, list): + mv = max_version(versions) + if mv: + return mv + min_v = info.get("min_version") + if isinstance(min_v, str) and min_v.strip(): + return min_v.strip() + return None + + +# ---------- TABLE VIEW (list / check) ---------- +def check_dependencies(): + tools = load_tools() + results = [] + + for tool, info in tools.items(): + # Defensive: ignore malformed tool entries in tools.yml (e.g. nested "tools:" key) + if not isinstance(info, dict): + continue + ttype = info.get("type") + status = "not installed" + version = "-" + + if ttype == "executable": + found = check_executable(info.get("check")) + + if found: + status = "installed" + if "version_cmd" in info: + version = get_version(info["version_cmd"]) or "-" + + elif ttype == "directory": + found = check_directory(info.get("path")) + if found: + status = "installed" + + results.append((tool.lower(), status, version)) + return results + + +def print_dependency_table(results): + print(f"{'Tool':<12} {'Status':<14} Version") + print("-" * 38) + + for tool, status, version in results: + print(f"{tool:<12} {status:<14} {version}") + + +# ---------- DOCTOR COMMAND ---------- +def run_doctor(): + print("🔍 System Diagnostics\n") + + tools = load_tools() + system_ready = True + + for tool, info in tools.items(): + tool_name = tool.lower() + + if info.get("type") != "executable": + continue + + found = check_executable(info.get("check")) + + if not found: + print(f"✖ {tool_name} missing") + system_ready = False + continue + + # Tools without version rules are still required, just not version-gated. + if ( + "min_version" not in info + and "recommended_version" not in info + ): + print(f"✔ {tool_name} found") + continue + + installed_version = None + if "version_cmd" in info: + installed_version = get_version(info["version_cmd"]) + + if installed_version and "min_version" in info: + cmp = compare_versions(installed_version, info["min_version"]) + if cmp == -1: + print( + f"⚠ {tool_name} version outdated " + f"({installed_version} < {info['min_version']})" + ) + system_ready = False + continue + + if installed_version and "recommended_version" in info: + cmp = compare_versions(installed_version, info["recommended_version"]) + if cmp == -1: + print( + f"ℹ {tool_name} update recommended " + f"(v{installed_version} < {info['recommended_version']})" + ) + continue + + if installed_version: + print(f"✔ {tool_name} found (v{installed_version})") + else: + print(f"✔ {tool_name} found") + + print("\nStatus:", end=" ") + if system_ready: + print("✅ System ready") + else: + print("❌ System not ready") + + +#-----------------Update--------------- +def needs_update(tool_name, info): + """ + Returns True only if: + - tool is executable + - tool is installed + - AND version rules are defined + - AND installed version is outdated + """ + + # Only executables can be updated + if info.get("type") != "executable": + return False + + # 🔑 KEY RULE: no known target → no update + target = get_latest_target_version(info) + if not target: + return False + + # Tool must be installed + if not check_executable(info.get("check")): + return False + + # Detect version + installed_version = None + if "version_cmd" in info: + installed_version = get_version(info["version_cmd"]) + + if not installed_version: + return False + + return compare_versions(installed_version, target) == -1 diff --git a/python-tool-manager-gui/ghdl.py b/python-tool-manager-gui/ghdl.py new file mode 100644 index 000000000..eeb5eca8f --- /dev/null +++ b/python-tool-manager-gui/ghdl.py @@ -0,0 +1,294 @@ +import os +import yaml +import shutil +import subprocess +import platform +import urllib.request +import json +import tarfile +from datetime import datetime + +TOOLS_DIR = os.path.expanduser("~/esim-tools-bin") +os.makedirs(TOOLS_DIR, exist_ok=True) + +INSTALL_DETAILS_FILE = "install_details.yml" + + +# -------------------- BASIC UTILS -------------------- + +def get_os(): + system = platform.system().lower() + if "windows" in system: + return "windows" + if "linux" in system: + return "linux" + if "darwin" in system: + return "mac" + return None + + +def run(cmd, log=print, cwd=None): + log("> " + (cmd if isinstance(cmd, str) else " ".join(cmd))) + p = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + ) + + for line in iter(p.stdout.readline, ""): + if not line: + break + log(line.strip()) + + p.wait() + if p.returncode != 0: + raise RuntimeError("Command failed") + + +def tool_exists(name): + return shutil.which(name) is not None + + +def get_tool_cfg(tool_name): + with open("tools.yml", "r") as f: + data = yaml.safe_load(f) + return data["tools"][tool_name] + + +# -------------------- VERSION VALIDATION -------------------- + +def validate_version(cfg, version): + if version in ("", "latest"): + return + + valid_versions = cfg.get("versions", []) + if version not in valid_versions: + raise ValueError(f"Invalid version '{version}'. Allowed: {', '.join(valid_versions)}") + + +# -------------------- INSTALL DETAILS -------------------- + +def _load_install_details(): + if not os.path.exists(INSTALL_DETAILS_FILE): + return {"important_packages": []} + + with open(INSTALL_DETAILS_FILE, "r") as f: + return yaml.safe_load(f) or {"important_packages": []} + + +def _save_install_details(data): + with open(INSTALL_DETAILS_FILE, "w") as f: + yaml.safe_dump(data, f) + + +def _upsert(package, installed, version, path): + data = _load_install_details() + pkgs = data.setdefault("important_packages", []) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + entry = next((p for p in pkgs if p["package_name"] == package), None) + + payload = { + "package_name": package, + "version": version if installed else "-", + "installed": "Yes" if installed else "No", + "installed_date": now if installed else "-", + "install_directory": path if installed else {} + } + + if entry: + entry.update(payload) + else: + pkgs.append(payload) + + _save_install_details(data) + + +def _get_installed_entry(package): + data = _load_install_details() + for p in data.get("important_packages", []): + if p.get("package_name") == package: + return p + return None + + +# -------------------- NETWORK -------------------- + +def _http_get_json(url): + req = urllib.request.Request(url, headers={"User-Agent": "tool-manager"}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read().decode()) + + +def _download(url, dest, log=print): + try: + log(f"Downloading {url}") + with urllib.request.urlopen(url, timeout=60) as r: + with open(dest, "wb") as f: + f.write(r.read()) + except Exception as e: + raise RuntimeError(f"Download failed: {e}") + + +# -------------------- GITHUB -------------------- + +def _fetch_release(cfg, version): + if version in ("", "latest"): + url = cfg["github"]["latest_api"] + else: + tag = version if version.startswith("v") else f"v{version}" + url = cfg["github"]["tag_api"].format(tag=tag) + + data = _http_get_json(url) + return data.get("tag_name"), data.get("assets", []) + + +def _pick_windows_asset(assets): + keywords = ["windows", "win64", "mingw"] + + for a in assets: + name = a.get("name", "").lower() + url = a.get("browser_download_url", "") + if url.endswith(".zip") and any(k in name for k in keywords): + return url + + return None + + +# -------------------- INSTALL -------------------- + +def install_ghdl(version="latest", log=print): + cfg = get_tool_cfg("GHDL") + os_type = get_os() + + validate_version(cfg, version) + + + if tool_exists(cfg["check"]): + log("GHDL already installed") + return + + if os_type == "windows": + tag, assets = _fetch_release(cfg, version) + url = _pick_windows_asset(assets) + + if not url: + raise RuntimeError("No Windows build found") + + zip_path = os.path.join(TOOLS_DIR, "ghdl.zip") + out_dir = os.path.join(TOOLS_DIR, "ghdl") + + _download(url, zip_path, log) + + import zipfile + with zipfile.ZipFile(zip_path) as z: + z.extractall(out_dir) + + os.remove(zip_path) + + log(f"⚠️ Add {out_dir}/bin to PATH") + + _upsert("ghdl", True, tag, {"root": out_dir}) + log("Installed GHDL (Windows)") + + elif os_type == "linux": + deps = cfg.get("linux", {}).get("deps", []) + if deps: + run(["sudo", "apt-get", "install", "-y"] + deps, log) + + _install_from_source(cfg, version, log) + + elif os_type == "mac": + if tool_exists("brew"): + run(["brew", "install"] + cfg.get("mac", {}).get("deps", []), log) + + _install_from_source(cfg, version, log) + + +def _install_from_source(cfg, version, log): + if version in ("", "latest"): + version = cfg["recommended_version"] + + tarballs = cfg.get("source_tarballs", {}) + if version not in tarballs: + raise RuntimeError("Version not supported") + + url = tarballs[version] + + work = os.path.join(TOOLS_DIR, "ghdl-src") + os.makedirs(work, exist_ok=True) + + tar_path = os.path.join(work, "ghdl.tar.gz") + _download(url, tar_path, log) + + with tarfile.open(tar_path) as tar: + tar.extractall(work) + + dirs = [d for d in os.listdir(work) if os.path.isdir(os.path.join(work, d))] + src = next((os.path.join(work, d) for d in dirs if "ghdl" in d.lower()), None) + + if not src: + raise RuntimeError("GHDL source dir not found") + + try: + run(["./configure"], log, cwd=src) + run(["make"], log, cwd=src) + run(["sudo", "make", "install"], log, cwd=src) + except Exception as e: + shutil.rmtree(work, ignore_errors=True) + raise e + + shutil.rmtree(work, ignore_errors=True) + + install_paths = { + "bin": shutil.which("ghdl") or "/usr/local/bin/ghdl", + "lib": "/usr/local/lib/ghdl", + "root": "/usr/local" + } + + _upsert("ghdl", True, version, install_paths) + log("Installed GHDL from source") + + +# -------------------- UNINSTALL -------------------- + +def uninstall_ghdl(log=print): + os_type = get_os() + + log("=== UNINSTALLING GHDL ===") + + entry = _get_installed_entry("ghdl") + + if not entry or entry.get("installed") != "Yes": + log("GHDL is not installed") + return + + paths = entry.get("install_directory", {}) + + if os_type == "windows": + root = paths.get("root") + if root and os.path.exists(root): + shutil.rmtree(root) + log("Removed GHDL directory") + + elif os_type == "linux": + for key, p in paths.items(): + if p and os.path.exists(p): + run(["sudo", "rm", "-rf", p], log) + log(f"Removed {key}: {p}") + + elif os_type == "mac": + if tool_exists("brew"): + run(["brew", "uninstall", "ghdl"], log) + else: + for key, p in paths.items(): + if p and os.path.exists(p): + run(["sudo", "rm", "-rf", p], log) + + _upsert("ghdl", False, "-", {}) + log("GHDL uninstalled successfully") + + diff --git a/python-tool-manager-gui/gui.py b/python-tool-manager-gui/gui.py new file mode 100644 index 000000000..c07128e37 --- /dev/null +++ b/python-tool-manager-gui/gui.py @@ -0,0 +1,341 @@ +import importlib.metadata +import os +import sys +import subprocess +import yaml + +from PyQt5.QtCore import QObject, Qt, pyqtSignal, QRunnable, QThreadPool, pyqtSlot +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import * + +try: + from tool_manager_gui import dependency, installer +except ImportError: + import dependency + import installer + + +# ---------------- SIGNALS ---------------- +class WorkerSignals(QObject): + log = pyqtSignal(str) + error = pyqtSignal(str) + finished = pyqtSignal() + progress = pyqtSignal(str, int) + + +# ---------------- WORKER ---------------- +class Worker(QRunnable): + def __init__(self, fn): + super().__init__() + self.fn = fn + self.signals = WorkerSignals() + + @pyqtSlot() + def run(self): + try: + self.fn(self.signals) + except Exception as e: + self.signals.error.emit(str(e)) + finally: + self.signals.finished.emit() + + +# ---------------- GUI ---------------- +class ToolManagerGUI(QMainWindow): + def __init__(self): + super().__init__() + + self.threadpool = QThreadPool() + self.progress_bars = {} + self.log_buffer = [] + + self.setWindowTitle(f"eSim Tool Manager - v{self.show_version()}") + self.setGeometry(400, 200, 900, 550) + + self.tools_data = self.load_tools_yaml() + + self.init_ui() + self.update_dependency_status() + + # ---------- YAML ---------- + def load_tools_yaml(self): + try: + path = os.path.join(os.path.dirname(__file__), "tools.yml") + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return data.get("tools", {}) + except Exception as e: + print("YAML Error:", e) + return {} + + # ---------- UI ---------- + def init_ui(self): + central = QWidget() + self.setCentralWidget(central) + + layout = QVBoxLayout() + + title = QLabel("eSim Tool Manager") + title.setFont(QFont("Arial", 18, QFont.Bold)) + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + self.table = QTableWidget() + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels( + ["Tool", "Version", "Description", "Installed", "Status"] + ) + self.table.setRowCount(len(self.tools_data)) + + for row, (tool, info) in enumerate(self.tools_data.items()): + checkbox = QCheckBox(tool) + self.table.setCellWidget(row, 0, checkbox) + + combo = QComboBox() + combo.addItems(["latest"] + info.get("versions", [])) + self.table.setCellWidget(row, 1, combo) + + self.table.setItem(row, 2, QTableWidgetItem(info.get("description", ""))) + self.table.setItem(row, 3, QTableWidgetItem("-")) + self.table.setItem(row, 4, QTableWidgetItem("Checking...")) + + layout.addWidget(self.table) + + # Buttons + btn_layout = QHBoxLayout() + + self.install_btn = QPushButton("Install") + self.install_btn.clicked.connect(self.install_tool) + btn_layout.addWidget(self.install_btn) + + self.update_btn = QPushButton("Update") + self.update_btn.clicked.connect(self.update_tool) + btn_layout.addWidget(self.update_btn) + + self.uninstall_btn = QPushButton("Uninstall") + self.uninstall_btn.clicked.connect(self.uninstall_tool) + btn_layout.addWidget(self.uninstall_btn) + + layout.addLayout(btn_layout) + + # Refresh + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.clicked.connect(self.update_dependency_status) + layout.addWidget(self.refresh_btn) + + # Output + self.output = QTextEdit() + self.output.setReadOnly(True) + layout.addWidget(self.output) + + # Command + cmd_layout = QHBoxLayout() + + self.command_input = QLineEdit() + self.command_input.returnPressed.connect(self.execute_command) + cmd_layout.addWidget(self.command_input) + + run_btn = QPushButton("Run") + run_btn.clicked.connect(self.execute_command) + cmd_layout.addWidget(run_btn) + + layout.addLayout(cmd_layout) + + central.setLayout(layout) + + # ---------- PROGRESS ---------- + def add_progress_column(self): + if self.table.columnCount() == 5: + self.table.insertColumn(5) + self.table.setHorizontalHeaderItem(5, QTableWidgetItem("Progress")) + + def remove_progress_column(self): + if self.table.columnCount() == 6: + self.table.removeColumn(5) + self.progress_bars.clear() + + def add_progress_bar(self, row, tool_name): + bar = QProgressBar() + bar.setRange(0, 100) + bar.setValue(0) + + bar.setStyleSheet(""" + QProgressBar { + border: none; + background-color: #1e1e2e; + border-radius: 8px; + text-align: center; + color: white; + } + QProgressBar::chunk { + background-color: #7aa2f7; + border-radius: 8px; + } + """) + + self.table.setCellWidget(row, 5, bar) + self.progress_bars[tool_name] = bar + + def update_progress(self, tool, value): + if tool in self.progress_bars: + self.progress_bars[tool].setValue(value) + + # ---------- LOG ---------- + def log(self, text): + self.log_buffer.append(text) + if len(self.log_buffer) >= 5: + self.output.append("\n".join(self.log_buffer)) + self.log_buffer.clear() + + # ---------- TASK RUNNER ---------- + def run_task(self, task): + worker = Worker(task) + + worker.signals.log.connect(self.log) + worker.signals.error.connect(lambda e: self.log(f"Error: {e}")) + worker.signals.progress.connect(self.update_progress) + worker.signals.finished.connect(self.task_done) + + self.threadpool.start(worker) + + def task_done(self): + self.update_dependency_status() + self.remove_progress_column() + + # ---------- SELECT ---------- + def get_selected(self): + selected = [] + for row in range(self.table.rowCount()): + if self.table.cellWidget(row, 0).isChecked(): + tool = self.table.cellWidget(row, 0).text() + version = self.table.cellWidget(row, 1).currentText() + selected.append((tool, version, row)) + return selected + + # ---------- ACTIONS ---------- + def install_tool(self): + selected = self.get_selected() + if not selected: + self.log("Select tools first") + return + + self.add_progress_column() + + for t, _, row in selected: + self.add_progress_bar(row, t) + + def task(signals): + total = len(selected) + + for i, (t, v, _) in enumerate(selected): + try: + signals.log.emit(f"Installing {t} ({v})...") + installer.install_tool(t, version=v, log=signals.log.emit) + + progress = int(((i + 1) / total) * 100) + signals.progress.emit(t, progress) + + except Exception as e: + signals.log.emit(f"{t} failed: {e}") + + self.run_task(task) + + def update_tool(self): + selected = self.get_selected() + if not selected: + self.log("Select tools first") + return + + self.add_progress_column() + + for t, _, row in selected: + self.add_progress_bar(row, t) + + def task(signals): + results = dependency.check_dependencies() + installed_map = {t.lower(): status for t, status, _ in results} + + total = len(selected) + + for i, (t, _, _) in enumerate(selected): + try: + if installed_map.get(t.lower()) != "installed": + signals.log.emit(f"{t} not installed. Skipping.") + continue + + installer.install_tool(t, version="latest", log=signals.log.emit) + + progress = int(((i + 1) / total) * 100) + signals.progress.emit(t, progress) + + except Exception as e: + signals.log.emit(f"{t} update failed: {e}") + + self.run_task(task) + + def uninstall_tool(self): + selected = self.get_selected() + if not selected: + self.log("Select tools first") + return + + self.add_progress_column() + + for t, _, row in selected: + self.add_progress_bar(row, t) + + def task(signals): + total = len(selected) + + for i, (t, _, _) in enumerate(selected): + try: + installer.uninstall_tool(t, log=signals.log.emit) + + progress = int(((i + 1) / total) * 100) + signals.progress.emit(t, progress) + + except Exception as e: + signals.log.emit(f"{t} uninstall failed: {e}") + + self.run_task(task) + + # ---------- STATUS ---------- + def update_dependency_status(self): + try: + results = dependency.check_dependencies() + data = {t.lower(): (s, v) for t, s, v in results} + + for row in range(self.table.rowCount()): + tool = self.table.cellWidget(row, 0).text().lower() + status, version = data.get(tool, ("not installed", "-")) + + self.table.setItem(row, 3, QTableWidgetItem(version)) + self.table.setItem(row, 4, QTableWidgetItem(status)) + + except Exception as e: + self.log(str(e)) + + # ---------- COMMAND ---------- + def execute_command(self): + cmd = self.command_input.text().strip() + self.command_input.clear() + self.log(f"> {cmd}") + + # ---------- VERSION ---------- + def show_version(self): + try: + return importlib.metadata.version("esim-tools-manager") + except: + return "0.2" + + +# ---------- MAIN ---------- +def main(): + app = QApplication(sys.argv) + win = ToolManagerGUI() + win.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/python-tool-manager-gui/install_details.yml b/python-tool-manager-gui/install_details.yml new file mode 100644 index 000000000..e69de29bb diff --git a/python-tool-manager-gui/installer.py b/python-tool-manager-gui/installer.py new file mode 100644 index 000000000..28987d097 --- /dev/null +++ b/python-tool-manager-gui/installer.py @@ -0,0 +1,180 @@ +import os +import platform +import shutil +from pathlib import Path + +try: + from .dependency import load_tools, needs_update + from .ghdl import install_ghdl + from .kicad import install_kicad + from .llmv import install_llvm + from .ngspice import install_ngspice + from .python import install_python + from .verilator import install_verilator +except ImportError: + from dependency import load_tools, needs_update + from ghdl import install_ghdl + from kicad import install_kicad + from llmv import install_llvm + from ngspice import install_ngspice + from python import install_python + from verilator import install_verilator + +TOOLS_DIR = os.path.expanduser("~/esim-tools-bin") +os.makedirs(TOOLS_DIR, exist_ok=True) + +INSTALLERS = { + "ngspice": install_ngspice, + "kicad": install_kicad, + "ghdl": install_ghdl, + "llvm": install_llvm, + "verilator": install_verilator, + "python": install_python, +} + + +def _platform_name(): + system = platform.system().lower() + if "windows" in system: + return "windows" + if "darwin" in system: + return "mac" + if "linux" in system: + return "linux" + return "unknown" + + +def normalize_tool_name(tool_name): + if not tool_name: + return "" + return str(tool_name).strip().lower() + + +def get_supported_tools(): + return list(INSTALLERS.keys()) + + +def _display_name(tool_name): + tools = load_tools() + canonical = normalize_tool_name(tool_name) + for raw_name in tools: + if normalize_tool_name(raw_name) == canonical: + return raw_name + return canonical + + +def _run_shell_command(command, log=print): + log(f"> {command}") + result = os.system(command) + if result != 0: + raise RuntimeError(f"Command failed with exit status {result}: {command}") + + +def _uninstall_python(log=print): + venv_path = Path.cwd() / "toolmanagervenv" + if not venv_path.exists(): + log("Python virtual environment is not present.") + return + shutil.rmtree(venv_path) + log(f"Removed virtual environment at {venv_path}") + + +def uninstall_tool(tool_name, log=print): + canonical = normalize_tool_name(tool_name) + if canonical not in INSTALLERS: + raise ValueError(f"Unknown tool: {tool_name}") + + if canonical == "python": + _uninstall_python(log) + return + + os_type = _platform_name() + package_name = canonical + + if os_type == "linux": + if canonical == "llvm": + _run_shell_command("sudo apt-get remove -y llvm clang", log) + else: + _run_shell_command(f"sudo apt-get remove -y {package_name}", log) + return + + if os_type == "mac": + if canonical == "kicad": + _run_shell_command("brew uninstall --cask kicad", log) + else: + _run_shell_command(f"brew uninstall {package_name}", log) + return + + if os_type == "windows": + _run_shell_command(f"choco uninstall -y {package_name}", log) + return + + raise RuntimeError("Unsupported OS") + + +def install_tool(tool_name, version="latest", log=print): + canonical = normalize_tool_name(tool_name) + if canonical not in INSTALLERS: + raise ValueError(f"Unknown tool: {tool_name}") + + display_name = _display_name(canonical) + log(f"Installing {display_name} ({version})...") + INSTALLERS[canonical](version=version, log=log) + log(f"{display_name} install complete") + + +def update_tool(tool_name=None, version="latest", log=print): + tools = load_tools() + + if tool_name: + canonical = normalize_tool_name(tool_name) + info = None + for raw_name, raw_info in tools.items(): + if normalize_tool_name(raw_name) == canonical: + info = raw_info + break + + if canonical not in INSTALLERS: + raise ValueError(f"Unknown tool: {tool_name}") + + if info and not needs_update(canonical, info): + log(f"{_display_name(canonical)} is already up to date or has no managed update policy.") + return + + install_tool(canonical, version=version, log=log) + return + + update_all(log=log) + + +def update_all(log=print): + tools = load_tools() + updated_any = False + + for raw_name, info in tools.items(): + canonical = normalize_tool_name(raw_name) + + if canonical not in INSTALLERS: + if needs_update(canonical, info): + log(f"{raw_name} has an update recommendation but is not managed automatically.") + continue + + if needs_update(canonical, info): + log(f"Updating {raw_name}...") + install_tool(canonical, log=log) + updated_any = True + + if not updated_any: + log("All managed tools are already up to date.") + + +def install_all(log=print): + tools = load_tools() + log("Installing all managed tools...") + + for raw_name in tools: + canonical = normalize_tool_name(raw_name) + if canonical in INSTALLERS: + install_tool(canonical, log=log) + + log("All managed tool install steps completed.") diff --git a/python-tool-manager-gui/kicad.py b/python-tool-manager-gui/kicad.py new file mode 100644 index 000000000..c4e31641e --- /dev/null +++ b/python-tool-manager-gui/kicad.py @@ -0,0 +1,663 @@ +import json +import os +import platform +import re +import shutil +import subprocess +import urllib.error +import urllib.request +import time +from datetime import datetime + +import yaml + +try: + from .version import get_version +except ImportError: + from version import get_version + + +TOOLS_DIR = os.path.expanduser("~/esim-tools-bin") +os.makedirs(TOOLS_DIR, exist_ok=True) + +BASE_DIR = os.path.dirname(__file__) +TOOLS_FILE = os.path.join(BASE_DIR, "tools.yml") +INSTALL_DETAILS_FILE = os.path.join(BASE_DIR, "install_details.yml") + +KICAD_RELEASES_API = ( + "https://api.github.com/repos/KiCad/kicad-source-mirror/releases?per_page=100" +) + + + +def get_os(): + # Keep OS naming aligned with the rest of the tool manager modules. + system = platform.system().lower() + if "windows" in system: + return "windows" + if "linux" in system: + return "linux" + if "darwin" in system: + return "mac" + return None + + +def run(cmd, log=print, cwd=None): + display_cmd = cmd if isinstance(cmd, str) else " ".join(cmd) + log(f"> {display_cmd}") + + try: + process = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + ) + + for line in iter(process.stdout.readline, ""): + if not line: + break + log(line.rstrip()) + + process.wait() + + if process.returncode != 0: + log(f"[ERROR] Command failed: {display_cmd}") + return False + + return True + + except Exception as e: + log(f"[ERROR] {e}") + return False + + +def load_tools(): + # Parse and validate the shared tool manifest once per call. + try: + with open(TOOLS_FILE, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + except Exception as exc: + raise RuntimeError(f"Failed to load tools.yml: {exc}") from exc + + tools = data.get("tools", {}) + if not isinstance(tools, dict): + raise RuntimeError("Invalid tools.yml: 'tools' must be a mapping") + return tools + + +def get_kicad_config(): + # Match the tool by normalized name so YAML display casing does not matter. + tools = load_tools() + for name, config in tools.items(): + if str(name).strip().lower() == "kicad": + if not isinstance(config, dict): + raise RuntimeError("Invalid Kicad config in tools.yml") + return config + raise RuntimeError("Kicad config not found in tools.yml") + + +def tool_exists(names): + if isinstance(names, str): + names = [names] + return any(shutil.which(name) for name in names) + + +def find_kicad_install_path(): + # KiCad is not always on PATH, especially for app-bundle installs on macOS. + config = get_kicad_config() + candidates = [] + + for path in config.get("install_paths", []): + if path == "auto": + auto_path = shutil.which("kicad") + if auto_path: + candidates.append(auto_path) + else: + candidates.append(path) + + for path in candidates: + if path and os.path.exists(path): + return path + + return "-" + + +def detect_installed_kicad_version(config): + # Prefer the configured version command, then fall back to path-based detection. + version_cmd = config.get("version_cmd") + version = get_version(version_cmd) + if version: + return version + install_path = find_kicad_install_path() + if install_path not in ("", "-"): + return "installed" + return None + + +def _load_install_details(): + try: + if not os.path.exists(INSTALL_DETAILS_FILE): + return {"important_packages": []} + with open(INSTALL_DETAILS_FILE, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + return {"important_packages": []} + packages = data.get("important_packages") + if not isinstance(packages, list): + data["important_packages"] = [] + return data + except Exception: + return {"important_packages": []} + + +def _save_install_details(data): + with open(INSTALL_DETAILS_FILE, "w", encoding="utf-8") as handle: + yaml.safe_dump(data, handle, sort_keys=False) + + +def _upsert_install_details(package_name, installed, version, install_directory): + # Preserve other package entries while updating the current tool in place. + data = _load_install_details() + packages = data.setdefault("important_packages", []) + + entry = None + for pkg in packages: + if isinstance(pkg, dict) and str(pkg.get("package_name", "")).lower() == package_name.lower(): + entry = pkg + break + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + payload = { + "package_name": package_name, + "version": version if installed else "-", + "installed": "Yes" if installed else "No", + "installed_date": now if installed else "-", + "install_directory": install_directory if installed else "-", + } + + if entry is None: + packages.append(payload) + else: + entry.update(payload) + + _save_install_details(data) + + +def _fetch_release_assets(log=print): + # Query release metadata so we can match real asset names instead of guessing URLs. + req = urllib.request.Request( + KICAD_RELEASES_API, + headers={"User-Agent": "tool_manager_gui"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.URLError as exc: + log(f"Failed to query KiCad releases: {exc}") + return [] + except Exception as exc: + log(f"Failed to parse KiCad release data: {exc}") + return [] + + releases = [] + if not isinstance(payload, list): + return releases + + for item in payload: + if not isinstance(item, dict): + continue + tag_name = str(item.get("tag_name", "")).strip() + assets = item.get("assets", []) + if tag_name: + releases.append({"tag_name": tag_name, "assets": assets if isinstance(assets, list) else []}) + return releases + + +def _release_sort_key(tag): + # Stable releases sort ahead of prereleases with the same numeric version. + match = re.match(r"^v?(\d+)\.(\d+)\.(\d+)(.*)$", tag) + if not match: + return (-1, -1, -1, -1, tag) + major, minor, patch = (int(match.group(i)) for i in range(1, 4)) + suffix = (match.group(4) or "").lower() + stable_bonus = 1 if not suffix else 0 + return (major, minor, patch, stable_bonus, suffix) + + +def resolve_requested_version(requested, available_versions): + # Support "latest", exact versions, and short prefixes like "7" or "7.0". + requested = (requested or "").strip() + if not available_versions: + return requested or "latest" + if requested == "" or requested.lower() == "latest": + return available_versions[0] + if requested in available_versions: + return requested + + prefix = requested + if re.fullmatch(r"\d+\.\d+", requested): + prefix = requested + "." + elif re.fullmatch(r"\d+", requested): + prefix = requested + "." + + matches = [version for version in available_versions if str(version).startswith(prefix)] + if matches: + return sorted(matches, key=_release_sort_key, reverse=True)[0] + return requested + + +def _select_release_for_version(version, releases): + # Normalize GitHub tags like "v8.0.0" against plain requested versions like "8.0.0". + if not releases: + return None + + tags = [release["tag_name"] for release in releases] + target_tag = resolve_requested_version(version, tags) + if target_tag.lower() == "latest": + return releases[0] + + for release in releases: + if release["tag_name"] == target_tag: + return release + + normalized_target = target_tag.lstrip("v") + for release in releases: + if release["tag_name"].lstrip("v") == normalized_target: + return release + return None + + +def _find_asset_url(release, patterns): + # Match assets by filename hints because KiCad release names vary across platforms. + if not release: + return None + for asset in release.get("assets", []): + if not isinstance(asset, dict): + continue + name = str(asset.get("name", "")).lower() + url = str(asset.get("browser_download_url", "")).strip() + if not url: + continue + if all(pattern in name for pattern in patterns): + return url, asset.get("name") or os.path.basename(url) + return None + + +def _download(url, dest_path, log=print, progress_cb=None): + """ + Download file with real progress, speed, ETA. + progress_cb(percent, speed_MBps, eta_sec) -> optional for GUI + """ + + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + log(f"Downloading: {url}") + + req = urllib.request.Request(url, headers={"User-Agent": "tool_manager_gui"}) + + try: + with urllib.request.urlopen(req, timeout=120) as response: + total_size = int(response.getheader("Content-Length", 0)) + downloaded = 0 + chunk_size = 1024 * 1024 # 1 MB + start_time = time.time() + + with open(dest_path, "wb") as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + + f.write(chunk) + downloaded += len(chunk) + + elapsed = time.time() - start_time + speed = downloaded / (1024 * 1024) / elapsed if elapsed > 0 else 0 + remaining = total_size - downloaded + eta = (remaining / (1024 * 1024) / speed) if speed > 0 else 0 + + percent = (downloaded / total_size * 100) if total_size else 0 + + msg = f"{percent:.1f}% | {speed:.2f} MB/s | ETA: {eta:.1f}s" + log(msg) + + if progress_cb: + progress_cb(percent, speed, eta) + + except Exception as e: + raise RuntimeError(f"Download failed: {e}") + + log("Download complete ✅") + return dest_path + + +def _choco_available(): + return tool_exists("choco") + + +def _choco_list_versions(log=print): + # Chocolatey gives us the most reliable version-pinned install path on Windows. + try: + result = subprocess.run( + "choco search kicad --exact --all-versions", + shell=True, + check=True, + capture_output=True, + text=True, + ) + except Exception as exc: + log(f"Failed to fetch KiCad versions from Chocolatey: {exc}") + return [] + + versions = [] + for line in result.stdout.splitlines(): + line = line.strip() + if not line.lower().startswith("kicad "): + continue + parts = line.split() + if len(parts) >= 2: + versions.append(parts[1].strip()) + return versions + + +def _ppa_exists(ppa_keyword): + """Check if a PPA is already added.""" + sources_dir = "/etc/apt/sources.list.d" + if not os.path.exists(sources_dir): + return False + return any(ppa_keyword in fname for fname in os.listdir(sources_dir)) + + +def install_kicad_linux(version, log=print): + log(f"Installing KiCad on Linux ({version})") + + config = get_kicad_config() + linux_config = config.get("linux", {}) + ppas = linux_config.get("ppas", {}) + + version_str = str(version).lower() + + # 🧠 Find matching PPA + selected_ppa = None + for prefix, ppa_info in ppas.items(): + if version_str.startswith(prefix): + selected_ppa = ppa_info + break + + # ➕ Add PPA + if selected_ppa: + repo = selected_ppa.get("repo") + key = selected_ppa.get("key") + + if repo and not _ppa_exists(key): + log(f"Adding PPA: {repo}") + run(["sudo", "add-apt-repository", "-y", repo], log) + else: + log("PPA already exists or invalid config, skipping...") + + # 🔄 Update + run(["sudo", "apt-get", "update"], log) + + # 📦 Install + if version_str == "latest": + run(["sudo", "apt-get", "install", "-y", "kicad"], log) + else: + version_prefix = f"{version}*" + run(["sudo", "apt-get", "install", "-y", f"kicad={version_prefix}"], log) + + +def install_kicad_windows(version, log=print): + if _choco_available(): + available_versions = _choco_list_versions(log) + chosen = resolve_requested_version(version, available_versions) + if str(chosen).lower() == "latest": + run("choco install -y kicad", log) + elif chosen in available_versions: + run(f"choco install -y kicad --version={chosen}", log) + else: + log("Requested version not found in Chocolatey. Falling back to official installer.") + chosen = None + + if chosen is not None: + return chosen, find_kicad_install_path() + + # Fall back to the official release artifact if Chocolatey is unavailable or insufficient. + releases = _fetch_release_assets(log) + release = _select_release_for_version(version, releases) + asset = _find_asset_url(release, patterns=["x86_64", ".exe"]) + if asset is None: + asset = _find_asset_url(release, patterns=[".exe"]) + if asset is None: + raise RuntimeError("No suitable KiCad Windows installer asset found.") + + url, filename = asset + installer_path = os.path.join(TOOLS_DIR, filename) + _download(url, installer_path, log) + run([installer_path, "/S"], log) + + installed_version = release["tag_name"].lstrip("v") if release else version + return installed_version, find_kicad_install_path() + + + +def install_kicad(version="latest", log=print, progress_cb=None): + # The public entrypoint keeps policy decisions here and delegates OS details below. + config = get_kicad_config() + os_type = get_os() + if os_type is None: + raise RuntimeError("Unsupported OS") + + installed_version = detect_installed_kicad_version(config) + if installed_version: + install_path = find_kicad_install_path() + log(f"KiCad already installed (version: {installed_version}). Skipping installation.") + _upsert_install_details("kicad", True, installed_version, install_path) + return + + available_versions = config.get("versions", []) + selected_version = resolve_requested_version(version, available_versions) + log(f"Requested KiCad version: {version}") + log(f"Resolved KiCad version: {selected_version}") + + if os_type == "linux": + install_kicad_linux(selected_version, log) + final_version = detect_installed_kicad_version(config) or selected_version + install_path = find_kicad_install_path() + elif os_type == "windows": + final_version, install_path = install_kicad_windows(selected_version, log) + elif os_type == "mac": + final_version, install_path = install_kicad_macos(selected_version, log) + else: + raise RuntimeError("Unsupported OS") + + _upsert_install_details("kicad", True, final_version, install_path) + log(f"KiCad installation complete ({final_version})") + +# ========================= +# 🧹 FULL UNINSTALL HELPERS +# ========================= + +def _uninstall_kicad_linux(config, log=print): + log("Removing KiCad (Linux)...") + + try: + run(["sudo", "apt-get", "remove", "-y", "kicad"], log) + run(["sudo", "apt-get", "purge", "-y", "kicad"], log) + run(["sudo", "apt-get", "autoremove", "-y"], log) + + # 🔥 Remove PPAs from YAML + linux_config = config.get("linux", {}) + ppas = linux_config.get("ppas", {}) + + for _, ppa_info in ppas.items(): + repo = ppa_info.get("repo") + if repo: + log(f"Removing PPA: {repo}") + try: + run(["sudo", "add-apt-repository", "--remove", "-y", repo], log) + except Exception: + log(f"[WARN] Failed to remove PPA: {repo}") + + except Exception as e: + log(f"[ERROR] Linux uninstall failed: {e}") + raise + + +def _uninstall_kicad_windows(log=print): + log("Removing KiCad (Windows)...") + + try: + # 🔹 Try Chocolatey + if _choco_available(): + run("choco uninstall -y kicad", log) + return + + # 🔹 Try Winget + try: + run(["winget", "uninstall", "--id", "KiCad.KiCad", "-e", "--silent"], log) + return + except Exception: + log("[WARN] Winget uninstall failed, trying manual removal...") + + # 🔹 Fallback: manual delete (SAFE) + install_path = find_kicad_install_path() + + if install_path and os.path.exists(install_path): + # Safety check + if "kicad" in install_path.lower(): + shutil.rmtree(os.path.dirname(install_path), ignore_errors=True) + log("Removed KiCad manually") + else: + log("[WARN] Install path does not look like KiCad. Skipping delete.") + + except Exception as e: + log(f"[ERROR] Windows uninstall failed: {e}") + raise + + + +def install_kicad_macos(version, log=print, progress_cb=None): + if tool_exists("brew") and str(version).lower() == "latest": + log("Installing KiCad via Homebrew cask") + run(["brew", "install", "--cask", "kicad"], log) + return "latest", find_kicad_install_path() + + releases = _fetch_release_assets(log) + release = _select_release_for_version(version, releases) + + asset = _find_asset_url(release, patterns=["unified", ".dmg"]) + if asset is None: + asset = _find_asset_url(release, patterns=[".dmg"]) + + if asset is None: + raise RuntimeError("No suitable KiCad macOS DMG found.") + + url, filename = asset + dmg_path = os.path.join(TOOLS_DIR, filename) + + # 🔥 FIXED DOWNLOAD + _download(url, dmg_path, log, progress_cb) + + log("Mounting DMG...") + + attach_output = subprocess.check_output( + ["hdiutil", "attach", dmg_path, "-nobrowse"], + text=True, + ) + + mount_point = None + for line in attach_output.splitlines(): + if "/Volumes/" in line: + mount_point = line.split("\t")[-1].strip() if "\t" in line else line.split()[-1].strip() + break + + if not mount_point or not os.path.exists(mount_point): + raise RuntimeError("Failed to mount DMG") + + try: + app_path = None + for name in os.listdir(mount_point): + if name.lower().endswith(".app"): + app_path = os.path.join(mount_point, name) + break + + if not app_path: + raise RuntimeError("KiCad app not found in DMG") + + destination = os.path.join("/Applications", os.path.basename(app_path)) + + if os.path.exists(destination): + log("Removing old version...") + shutil.rmtree(destination) + + log("Installing KiCad...") + run(["ditto", app_path, destination], log) + + finally: + subprocess.run(["hdiutil", "detach", mount_point, "-quiet"], check=False) + + installed_version = release["tag_name"].lstrip("v") if release else version + log(f"Installed KiCad {installed_version} ✅") + + return installed_version, find_kicad_install_path() + + +def _cleanup_kicad_files(log=print): + log("Cleaning up KiCad config & cache...") + + paths = [ + os.path.expanduser("~/.config/kicad"), + os.path.expanduser("~/.cache/kicad"), + os.path.expanduser("~/.local/share/kicad"), + ] + + for path in paths: + if os.path.exists(path): + try: + shutil.rmtree(path) + log(f"Removed: {path}") + except Exception as e: + log(f"[WARN] Failed to remove {path}: {e}") + + +# ========================= +# 🧹 MAIN UNINSTALL FUNCTION +# ========================= + +def uninstall_kicad(log=print): + log("🧹 Starting KiCad FULL uninstall...") + + os_type = get_os() + config = get_kicad_config() + install_path = find_kicad_install_path() + + if install_path in ("", "-", None): + log("[SKIP] KiCad not found") + _upsert_install_details("kicad", False, "-", "-") + return + + try: + if os_type == "linux": + _uninstall_kicad_linux(config, log) + + elif os_type == "windows": + _uninstall_kicad_windows(log) + + elif os_type == "mac": + _uninstall_kicad_macos(log) + + else: + raise RuntimeError("Unsupported OS") + + # 🔥 Always cleanup + _cleanup_kicad_files(log) + + _upsert_install_details("kicad", False, "-", "-") + + log("✅ KiCad fully removed (app + PPA + configs + cache)") + + except Exception as e: + log(f"[ERROR] Uninstall failed: {e}") + raise \ No newline at end of file diff --git a/python-tool-manager-gui/llmv.py b/python-tool-manager-gui/llmv.py new file mode 100644 index 000000000..959e8c83a --- /dev/null +++ b/python-tool-manager-gui/llmv.py @@ -0,0 +1,257 @@ +import os +import re +import shutil +import subprocess +import platform +from datetime import datetime +import yaml + + +INSTALL_DETAILS_FILE = os.path.join(os.path.dirname(__file__), "install_details.yml") + + +# ---------------- OS ---------------- # + +def get_os(): + system = platform.system().lower() + if "windows" in system: + return "windows" + if "linux" in system: + return "linux" + if "darwin" in system: + return "mac" + return None + + +def tool_exists(name): + return shutil.which(name) is not None + + +# ---------------- RUN ---------------- # + +def run(cmd, log=print, cwd=None): + display = cmd if isinstance(cmd, str) else " ".join(cmd) + log(f"> {display}") + + p = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + ) + + if p.stdout: + for line in p.stdout: + log(line.rstrip()) + + p.wait() + if p.returncode != 0: + raise RuntimeError(f"Command failed: {display}") + + +# ---------------- VERSION DETECTION ---------------- # + +def detect_llvm_version(): + """ + Primary: llvm-config (REAL LLVM) + Fallback: clang ONLY if it's from brew/apt/choco (not system clang) + """ + + # ✅ Primary check + try: + path = shutil.which("llvm-config") + if path: + return subprocess.check_output( + ["llvm-config", "--version"], text=True + ).strip() + except: + pass + + # ⚠️ Fallback (only if not system clang) + try: + clang_path = shutil.which("clang") + if clang_path and "homebrew" in clang_path.lower() or "llvm" in clang_path.lower(): + out = subprocess.check_output(["clang", "--version"], text=True) + m = re.search(r"clang version\s+(\d+(\.\d+){0,2})", out, re.IGNORECASE) + if m: + return m.group(1) + except: + pass + + return None + + +def is_llvm_installed(): + """ + True only if REAL LLVM is installed (not system clang) + """ + return shutil.which("llvm-config") is not None + + +# ---------------- INSTALL DETAILS ---------------- # + +def _load(): + if not os.path.exists(INSTALL_DETAILS_FILE): + return {"important_packages": []} + with open(INSTALL_DETAILS_FILE) as f: + return yaml.safe_load(f) or {"important_packages": []} + + +def _save(data): + with open(INSTALL_DETAILS_FILE, "w") as f: + yaml.safe_dump(data, f, sort_keys=False) + + +def _upsert(name, installed, version, path): + data = _load() + pkgs = data.setdefault("important_packages", []) + + entry = next((p for p in pkgs if p.get("package_name") == name), None) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + payload = { + "package_name": name, + "version": version if installed else "-", + "installed": "Yes" if installed else "No", + "installed_date": now if installed else "-", + "install_directory": path if installed else "-", + } + + if entry: + entry.update(payload) + else: + pkgs.append(payload) + + _save(data) + + +# ---------------- WINDOWS VERSION SUPPORT ---------------- # + +def _choco_versions(log): + try: + result = subprocess.run( + "choco search llvm --exact --all-versions", + shell=True, + capture_output=True, + text=True + ) + versions = [] + for line in result.stdout.splitlines(): + if line.lower().startswith("llvm "): + versions.append(line.split()[1]) + return versions + except Exception as e: + log(f"Failed to fetch versions: {e}") + return [] + + +def _pick_version(requested, versions): + if requested in ("", "latest"): + return sorted(versions, reverse=True)[0] if versions else "latest" + + if requested in versions: + return requested + + prefix = requested + "." + matches = [v for v in versions if v.startswith(prefix)] + + return sorted(matches, reverse=True)[0] if matches else None + + +# ---------------- INSTALL ---------------- # + +def install_llvm(version="latest", log=print): + log("=== INSTALLING LLVM ===") + + os_type = get_os() + + # ✅ Correct check + if is_llvm_installed(): + v = detect_llvm_version() or "-" + log(f"LLVM already installed ({v})") + _upsert("llvm", True, v, shutil.which("llvm-config")) + return + + if os_type == "windows": + if not tool_exists("choco"): + raise RuntimeError("Chocolatey not installed") + + versions = _choco_versions(log) + chosen = _pick_version(version, versions) + + if chosen == "latest": + run("choco install -y llvm", log) + elif chosen: + run(f"choco install -y llvm --version={chosen}", log) + else: + raise RuntimeError(f"Version {version} not found") + + elif os_type == "mac": + if not tool_exists("brew"): + raise RuntimeError("Homebrew not installed") + + run("brew install llvm", log) + + # 🔥 fix PATH (important for detection) + try: + prefix = subprocess.check_output(["brew", "--prefix", "llvm"], text=True).strip() + os.environ["PATH"] += os.pathsep + os.path.join(prefix, "bin") + except: + pass + + elif os_type == "linux": + run("sudo apt-get update", log) + run("sudo apt-get install -y llvm clang", log) + + else: + log("Unsupported OS") + return + + v = detect_llvm_version() or version + _upsert("llvm", True, v, shutil.which("llvm-config")) + + log(f"=== INSTALLED LLVM {v} ===") + + +# ---------------- UNINSTALL ---------------- # + +def uninstall_llvm(log=print): + log("=== UNINSTALLING LLVM ===") + + os_type = get_os() + + if not is_llvm_installed(): + log("LLVM not installed (real LLVM not found)") + _upsert("llvm", False, "-", "-") + return + + try: + if os_type == "windows": + run("choco uninstall -y llvm", log) + + elif os_type == "mac": + # try both + try: + run("brew uninstall llvm", log) + except: + log("[INFO] llvm not found in brew") + + run("brew cleanup", log) + + elif os_type == "linux": + run("sudo apt-get remove -y llvm clang", log) + run("sudo apt-get autoremove -y", log) + + else: + log("Unsupported OS") + return + + except Exception as e: + log(f"[ERROR] {e}") + return + + _upsert("llvm", False, "-", "-") + log("=== LLVM REMOVED ===") \ No newline at end of file diff --git a/python-tool-manager-gui/ngspice.py b/python-tool-manager-gui/ngspice.py new file mode 100644 index 000000000..b0a2a40f8 --- /dev/null +++ b/python-tool-manager-gui/ngspice.py @@ -0,0 +1,341 @@ +import os +import re +import shutil +import subprocess +import tarfile +import platform +import urllib.request +import tempfile +from datetime import datetime + +import yaml + + +BASE_DIR = os.path.dirname(__file__) +TOOLS_FILE = os.path.join(BASE_DIR, "tools.yml") +INSTALL_DETAILS_FILE = os.path.join(BASE_DIR, "install_details.yml") + + +# ---------------- OS ---------------- # + +def get_os(): + system = platform.system().lower() + if "windows" in system: + return "windows" + if "linux" in system: + return "linux" + if "darwin" in system: + return "mac" + return None + + +def run(cmd, log=print, cwd=None): + display = cmd if isinstance(cmd, str) else " ".join(cmd) + log(f"> {display}") + + p = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + ) + + if p.stdout: + for line in iter(p.stdout.readline, ""): + log(line.rstrip()) + + p.wait() + if p.returncode != 0: + log(f"Command failed: {display}") + return # don't crash whole app + + +# ---------------- YAML ---------------- # + +def load_ngspice_config(): + try: + with open(TOOLS_FILE, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + except Exception as e: + raise RuntimeError(f"Failed to load tools.yml: {e}") + + tools = data.get("tools", {}) + if "ngspice" not in tools: + raise RuntimeError("ngspice config not found in tools.yml") + + return tools["ngspice"] + + +# ---------------- INSTALL DETAILS ---------------- # + +def _load_install_details(): + if not os.path.exists(INSTALL_DETAILS_FILE): + return {"important_packages": []} + with open(INSTALL_DETAILS_FILE) as f: + return yaml.safe_load(f) or {"important_packages": []} + + +def _save_install_details(data): + with open(INSTALL_DETAILS_FILE, "w") as f: + yaml.safe_dump(data, f, sort_keys=False) + + +def _upsert(name, installed, version, path): + data = _load_install_details() + pkgs = data.setdefault("important_packages", []) + + entry = next((p for p in pkgs if p.get("package_name") == name), None) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + payload = { + "package_name": name, + "version": version if installed else "-", + "installed": "Yes" if installed else "No", + "installed_date": now if installed else "-", + "install_directory": path if installed else "-", + } + + if entry: + entry.update(payload) + else: + pkgs.append(payload) + + _save_install_details(data) + + +# ---------------- VERSION ---------------- # + +def tool_exists(name): + return shutil.which(name) is not None + + +def _extract_version(output): + patterns = [ + r"ngspice[-\s]*([\d\.]+)", + r"(\d+\.\d+(\.\d+)?)" + ] + + for p in patterns: + match = re.search(p, output, re.IGNORECASE) + if match: + return match.group(1) + + return None + + +def detect_version(cfg): + if not tool_exists(cfg.get("check")): + return None + + commands = cfg.get("version_cmd") + if not commands: + return None + + if isinstance(commands, str): + commands = [commands] + + for cmd in commands: + try: + result = subprocess.run( + cmd.split(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + output = (result.stdout or "") + (result.stderr or "") + version = _extract_version(output) + + if version: + return version + + except Exception: + continue + + return None + + +def resolve_version(requested, available): + requested = (requested or "").strip() + + if requested in ("", "latest"): + return available[0] + + if requested in available: + return requested + + prefix = requested + "." + matches = [v for v in available if v.startswith(prefix)] + + if matches: + return sorted(matches, reverse=True)[0] + + return requested + + +# ---------------- DOWNLOAD ---------------- # + +def _download(url, dest, log): + log(f"Downloading: {url}") + with urllib.request.urlopen(url) as r, open(dest, "wb") as f: + shutil.copyfileobj(r, f) + + +# ---------------- INSTALL ---------------- # + +def install_linux(version, cfg, log): + linux_cfg = cfg["install"]["linux"] + + run("sudo apt-get update", log) + + deps = linux_cfg.get("deps", []) + if deps: + run(["sudo", "apt-get", "install", "-y"] + deps, log) + + url = linux_cfg["url_template"].format(version=version) + + with tempfile.TemporaryDirectory() as tmp: + archive = os.path.join(tmp, "ngspice.tar.gz") + _download(url, archive, log) + + with tarfile.open(archive) as tar: + tar.extractall(tmp) + + src = next( + (os.path.join(tmp, d) for d in os.listdir(tmp) if d.startswith("ngspice-")), + None, + ) + + if not src: + raise RuntimeError("Source folder not found") + + for cmd in linux_cfg.get("build", []): + run(cmd, log, cwd=src) + + +def install_windows(version, cfg, log): + win_cfg = cfg["install"]["windows"] + + base_dir = os.path.join(os.getcwd(), "tools") + os.makedirs(base_dir, exist_ok=True) + + archive = os.path.join(base_dir, win_cfg["archive_name"].format(version=version)) + out_folder = os.path.join(base_dir, win_cfg["extract_folder"].format(version=version)) + + url = win_cfg["url_template"].format(version=version) + fallback = win_cfg["fallback_url_template"].format(version=version) + + # -------- Download with fallback -------- # + try: + _download(url, archive, log) + except Exception: + log("Main URL failed, trying fallback...") + try: + _download(fallback, archive, log) + except Exception: + raise RuntimeError(f"❌ Failed to download ngspice {version}") + + os.makedirs(out_folder, exist_ok=True) + + # -------- 7z check -------- # + if not tool_exists("7z"): + raise RuntimeError("❌ 7-Zip not found. Install it first.") + + run(f'7z x "{archive}" -o"{out_folder}"', log) + + log(f"✅ Ngspice {version} installed at {out_folder}") + log(f"👉 Add to PATH: {out_folder}") + + +def install_mac(cfg, log): + if not tool_exists("brew"): + raise RuntimeError("brew not installed") + + pkg = cfg["install"]["mac"].get("package", "ngspice") + run(f"brew install {pkg}", log) + + +def install_ngspice(version="latest", log=print): + log("=== INSTALLING NGSPICE ===") + + cfg = load_ngspice_config() + os_type = get_os() + + if tool_exists(cfg.get("check")): + v = detect_version(cfg) or "-" + log(f"Already installed ({v})") + _upsert("ngspice", True, v, shutil.which("ngspice")) + return + + available = cfg.get("versions", []) + version = resolve_version(version, available) + + log(f"Requested: {version}") + + if os_type == "linux": + install_linux(version, cfg, log) + + elif os_type == "windows": + install_windows(version, cfg, log) + + elif os_type == "mac": + install_mac(cfg, log) + + else: + raise RuntimeError("Unsupported OS") + + v = detect_version(cfg) or version + _upsert("ngspice", True, v, shutil.which("ngspice") or "-") + + log(f"=== INSTALLED NGSPICE {v} ===") + + +# ---------------- UNINSTALL ---------------- # + +def _cleanup(log=print): + paths = [ + os.path.expanduser("~/.ngspice"), + os.path.expanduser("~/.cache/ngspice"), + ] + + for p in paths: + if os.path.exists(p): + shutil.rmtree(p, ignore_errors=True) + log(f"Removed {p}") + + +def uninstall_ngspice(log=print): + log("=== UNINSTALLING NGSPICE ===") + + cfg = load_ngspice_config() + os_type = get_os() + + if not tool_exists(cfg.get("check")): + log("Not installed") + _upsert("ngspice", False, "-", "-") + return + + try: + if os_type == "linux": + run(["sudo", "apt-get", "remove", "-y", "ngspice"], log) + run(["sudo", "apt-get", "autoremove", "-y"], log) + + elif os_type == "windows": + run("choco uninstall -y ngspice", log) + + elif os_type == "mac": + run("brew uninstall ngspice", log) + + _cleanup(log) + + _upsert("ngspice", False, "-", "-") + + log("=== NGSPICE FULLY REMOVED ===") + + except Exception as e: + log(f"[ERROR] {e}") + return + diff --git a/python-tool-manager-gui/python.py b/python-tool-manager-gui/python.py new file mode 100644 index 000000000..5bc8e291e --- /dev/null +++ b/python-tool-manager-gui/python.py @@ -0,0 +1,443 @@ +import os +import re +import shutil +import subprocess +import sys +import platform +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed +import yaml + + +# ========================= +# 📁 PATHS +# ========================= +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +VENV_NAME = "toolmanagervenv" +VENV_PATH = os.path.join(BASE_DIR, VENV_NAME) + +TOOLS_FILE = os.path.join(BASE_DIR, "tools.yml") +INSTALL_DETAILS_YML = os.path.join(BASE_DIR, "install_details.yml") + + +# ========================= +# 🖥️ OS DETECTION +# ========================= +def get_os(): + system = platform.system().lower() + if "windows" in system: + return "windows" + if "linux" in system: + return "linux" + if "darwin" in system: + return "mac" + return None + + +# ========================= +# ⚙️ COMMAND RUNNER +# ========================= +def run(cmd, log=print, cwd=None): + display_cmd = cmd if isinstance(cmd, str) else " ".join(cmd) + log(f"> {display_cmd}") + + p = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + ) + + output = [] + + if p.stdout: + for line in iter(p.stdout.readline, ""): + if not line: + break + line = line.rstrip() + output.append(line) + log(line) + + p.wait() + if p.returncode != 0: + raise RuntimeError(f"Command failed: {display_cmd}\n" + "\n".join(output)) + + return "\n".join(output) + + +# ========================= +# 📄 YAML TRACKING +# ========================= +def _load_install_details_yml(): + if not os.path.exists(INSTALL_DETAILS_YML): + return {"important_packages": []} + + with open(INSTALL_DETAILS_YML, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + data.setdefault("important_packages", []) + return data + + +def _save_install_details_yml(data): + with open(INSTALL_DETAILS_YML, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, sort_keys=False) + + +def _upsert_install_details(package_name, installed, version, install_directory): + data = _load_install_details_yml() + pkgs = data["important_packages"] + + entry = next((p for p in pkgs if p["package_name"].lower() == package_name.lower()), None) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + payload = { + "package_name": package_name, + "version": version if installed else "-", + "installed": "Yes" if installed else "No", + "installed_date": now if installed else "-", + "install_directory": install_directory if installed else "-", + } + + if entry: + entry.update(payload) + else: + pkgs.append(payload) + + _save_install_details_yml(data) + + +# ========================= +# 📦 LOAD CONFIG +# ========================= +def _load_python_section(): + if not os.path.exists(TOOLS_FILE): + return [], [] + + with open(TOOLS_FILE, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + python_tool = data.get("tools", {}).get("Python", {}) + + return python_tool.get("pip_packages", []), python_tool.get("dependencies", []) + + +# ========================= +# 🧱 SYSTEM DEPENDENCIES +# ========================= +def _is_installed_cmd(cmd): + return shutil.which(cmd) is not None + + +def _install_system_package(pkg, log): + os_type = get_os() + + try: + if os_type == "linux": + run(["sudo", "apt-get", "install", "-y", pkg], log) + elif os_type == "mac": + run(["brew", "install", pkg], log) + elif os_type == "windows": + run(["winget", "install", "--id", pkg, "-e", "--silent"], log) + else: + raise RuntimeError("Unsupported OS") + + _upsert_install_details(pkg, True, "-", "/usr/bin") + return True + + except Exception as e: + log(f"[ERROR] {pkg} failed: {e}") + _upsert_install_details(pkg, False, "-", "-") + return False + + +def _handle_system_dependencies(deps, log=print): + missing = [] + + for dep in deps: + if _is_installed_cmd(dep): + log(f"[OK] {dep} already installed") + _upsert_install_details(dep, True, "-", "/usr/bin") + else: + missing.append(dep) + + if not missing: + return + + log(f"Installing {len(missing)} system packages...") + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(_install_system_package, pkg, log) for pkg in missing] + for f in as_completed(futures): + f.result() + + +def _update_system_packages(log=print): + os_type = get_os() + + try: + if os_type == "linux": + run(["sudo", "apt-get", "update"], log) + run(["sudo", "apt-get", "upgrade", "-y"], log) + elif os_type == "mac": + run(["brew", "update"], log) + run(["brew", "upgrade"], log) + elif os_type == "windows": + run(["winget", "upgrade", "--all"], log) + except Exception as e: + log(f"[ERROR] System update failed: {e}") + + +# ========================= +# 🐍 VENV + PIP +# ========================= +def _venv_python(): + if get_os() == "windows": + return os.path.join(VENV_PATH, "Scripts", "python.exe") + return os.path.join(VENV_PATH, "bin", "python") + + +def _ensure_venv(log=print): + if not os.path.exists(_venv_python()): + log("Creating virtual environment...") + run([sys.executable, "-m", "venv", VENV_PATH], log) + + +def _ensure_pip(log=print): + py = _venv_python() + run([py, "-m", "ensurepip", "--upgrade"], log) + run([py, "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"], log) + + +# ========================= +# 📦 VERSION HANDLING +# ========================= +def _get_pip_version(pkg): + py = _venv_python() + name = pkg.split("==")[0] + + try: + out = subprocess.check_output([py, "-m", "pip", "show", name], text=True) + for line in out.splitlines(): + if line.startswith("Version:"): + return line.split(":")[1].strip() + except Exception: + return None + + +def _get_latest_pip_version(pkg): + py = _venv_python() + name = pkg.split("==")[0] + + try: + out = subprocess.check_output([py, "-m", "pip", "index", "versions", name], text=True) + match = re.search(r"Available versions:\s*(.*)", out) + if match: + return match.group(1).split(",")[0].strip() + except Exception: + return None + + +def _pip_installed(pkg): + return _get_pip_version(pkg) is not None + + +# ========================= +# 📦 PIP INSTALL (PARALLEL) +# ========================= +def _pip_install(packages, log=print): + py = _venv_python() + + def install(pkg): + for i in range(3): + try: + log(f"Installing {pkg} (attempt {i+1})") + run([py, "-m", "pip", "install", pkg], log) + + version = _get_pip_version(pkg) + _upsert_install_details(pkg, True, version, VENV_PATH) + return + except Exception as e: + log(f"[RETRY] {pkg}: {e}") + + _upsert_install_details(pkg, False, "-", "-") + + with ThreadPoolExecutor(max_workers=4) as executor: + futures = [executor.submit(install, pkg) for pkg in packages] + for f in as_completed(futures): + f.result() + + +# ========================= +# 🔄 UPDATE PIP PACKAGES +# ========================= +def _update_pip_packages(packages, log=print): + py = _venv_python() + + for pkg in packages: + name = pkg.split("==")[0] + + current = _get_pip_version(pkg) + latest = _get_latest_pip_version(pkg) + + if not current or not latest: + log(f"[SKIP] {name} version check failed") + continue + + if current == latest: + log(f"[OK] {name} already latest ({current})") + continue + + log(f"[UPDATE] {name}: {current} → {latest}") + + try: + run([py, "-m", "pip", "install", "--upgrade", name], log) + + new_version = _get_pip_version(pkg) + _upsert_install_details(name, True, new_version, VENV_PATH) + + except Exception as e: + log(f"[ERROR] {name} update failed: {e}") + + +# ========================= +# 🚀 INSTALL +# ========================= +def install_python(log=print): + + _ensure_venv(log) + _ensure_pip(log) + + pip_packages, dependencies = _load_python_section() + + _handle_system_dependencies(dependencies, log) + + missing = [p for p in pip_packages if not _pip_installed(p)] + + if missing: + log(f"Installing {len(missing)} pip packages...") + _pip_install(missing, log) + + failed = [p for p in pip_packages if not _pip_installed(p)] + + if failed: + raise RuntimeError(f"Failed packages: {', '.join(failed)}") + + py_ver = subprocess.check_output([sys.executable, "--version"], text=True).strip() + _upsert_install_details("python", True, py_ver, sys.executable) + + log("✅ INSTALL COMPLETE") + + +# ========================= +# 🔄 UPDATE (BUTTON LOGIC) +# ========================= +def update_python(log=print): + + log("🔄 Checking for updates...") + + pip_packages, dependencies = _load_python_section() + + _update_pip_packages(pip_packages, log) + _update_system_packages(log) + + log("✅ UPDATE COMPLETE") + +# ========================= +# 🧹 UNINSTALL +# ========================= +def _pip_uninstall(packages, log=print): + py = _venv_python() + + def uninstall(pkg): + name = pkg.split("==")[0] + try: + if _pip_installed(pkg): + log(f"Uninstalling {name}...") + run([py, "-m", "pip", "uninstall", "-y", name], log) + _upsert_install_details(name, False, "-", "-") + else: + log(f"[SKIP] {name} not installed") + except Exception as e: + log(f"[ERROR] Failed to uninstall {name}: {e}") + _upsert_install_details(name, False, "-", "-") + + with ThreadPoolExecutor(max_workers=4) as executor: + futures = [executor.submit(uninstall, pkg) for pkg in packages] + for f in as_completed(futures): + f.result() + +## UNINSTALL + +def _uninstall_system_package(pkg, log=print): + os_type = get_os() + + try: + if os_type == "linux": + run(["sudo", "apt-get", "remove", "-y", pkg], log) + elif os_type == "mac": + run(["brew", "uninstall", pkg], log) + elif os_type == "windows": + run(["winget", "uninstall", "--id", pkg, "-e", "--silent"], log) + else: + raise RuntimeError("Unsupported OS") + + _upsert_install_details(pkg, False, "-", "-") + return True + + except Exception as e: + log(f"[ERROR] {pkg} uninstall failed: {e}") + return False + + +def _handle_uninstall_system_dependencies(deps, log=print): + log(f"Uninstalling {len(deps)} system packages...") + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(_uninstall_system_package, pkg, log) for pkg in deps] + for f in as_completed(futures): + f.result() + + +def _remove_venv(log=print): + if os.path.exists(VENV_PATH): + log("Removing virtual environment...") + try: + shutil.rmtree(VENV_PATH) + log("Venv removed") + except Exception as e: + log(f"[ERROR] Failed to remove venv: {e}") + else: + log("[SKIP] No virtual environment found") + + +# ========================= +# 🧨 MAIN UNINSTALL FUNCTION +# ========================= +def uninstall_python(log=print): + + log("🧹 Starting full uninstall...") + + pip_packages, dependencies = _load_python_section() + + # 1️⃣ Uninstall pip packages + if os.path.exists(_venv_python()): + _pip_uninstall(pip_packages, log) + else: + log("[SKIP] Venv not found, skipping pip uninstall") + + # 2️⃣ Remove system dependencies + if dependencies: + _handle_uninstall_system_dependencies(dependencies, log) + + # 3️⃣ Remove virtual environment + _remove_venv(log) + + # 4️⃣ Mark python as uninstalled + _upsert_install_details("python", False, "-", "-") + + log("✅ UNINSTALL COMPLETE") \ No newline at end of file diff --git a/python-tool-manager-gui/requirements.txt b/python-tool-manager-gui/requirements.txt new file mode 100644 index 000000000..4d7e64d3e --- /dev/null +++ b/python-tool-manager-gui/requirements.txt @@ -0,0 +1,6 @@ +PyQt5>=5.15 +<<<<<<< HEAD +PyYAML>=6.0 +======= +PyYAML>=6.0 +>>>>>>> 9538b1261ecbfad03466fcfd8e9694a5faac3225 diff --git a/python-tool-manager-gui/tools.yml b/python-tool-manager-gui/tools.yml new file mode 100644 index 000000000..9aec27af3 --- /dev/null +++ b/python-tool-manager-gui/tools.yml @@ -0,0 +1,183 @@ +tools: + ngspice: + description: "SPICE-based analog circuit simulator" + + type: executable + check: ngspice + version_cmd: "ngspice --version" + + versions: + - "45.2" + - "45.0" + - "44" + - "43" + - "42" + + min_version: "45.0" + recommended_version: "45.2" + + install: + windows: + method: archive + + url_template: "https://sourceforge.net/projects/ngspice/files/ng-spice-rework/{version}/ngspice-{version}_64.7z/download" + + fallback_url_template: "https://sourceforge.net/projects/ngspice/files/ng-spice-rework/old-releases/{version}/ngspice-{version}_64.7z/download" + + archive_name: "ngspice-{version}.7z" + extract_folder: "ngspice-{version}" + + linux: + method: source + url_template: "https://sourceforge.net/projects/ngspice/files/ng-spice-rework/{version}/ngspice-{version}.tar.gz/download" + deps: + - build-essential + - libx11-dev + - xorg-dev + - libxaw7-dev + - libreadline-dev + - wget + + build: + - "./configure" + - "make" + - "sudo make install" + + mac: + method: brew + package: ngspice + + Kicad: + description: "PCB design and schematic capture tool" + versions: ["8.0", "7.0.10", "7.0", "6.0"] + type: executable + check: kicad + version_cmd: kicad --version + install_paths: + - auto # means use shutil.which("kicad") + - "/Applications/KiCad/KiCad.app" + - "/Applications/KiCad.app" + - "C:\\Program Files\\KiCad\\bin\\kicad.exe" + - "C:\\Program Files\\KiCad\\bin\\kicad-cli.exe" + linux: + ppas: + "8": + repo: "ppa:kicad/kicad-8.0-releases" + key: "kicad-8.0" + "7": + repo: "ppa:kicad/kicad-7.0-releases" + key: "kicad-7.0" + "6": + repo: "ppa:kicad/kicad-6.0-releases" + key: "kicad-6.0" + + GHDL: + description: "VHDL simulator used in digital design" + versions: ["6.0.0", "5.0.0", "4.1.0", "3.0.0"] + type: executable + check: ghdl + version_cmd: ghdl --version + min_version: "4.1.0" + recommended_version: "4.1.0" + + github: + latest_api: "https://api.github.com/repos/ghdl/ghdl/releases/latest" + tag_api: "https://api.github.com/repos/ghdl/ghdl/releases/tags/{tag}" + + source_tarballs: + "4.1.0": "https://github.com/ghdl/ghdl/archive/refs/tags/v4.1.0.tar.gz" + "3.0.0": "https://github.com/ghdl/ghdl/archive/refs/tags/v3.0.0.tar.gz" + + linux: + deps: ["build-essential", "gcc", "gnat", "zlib1g-dev", "wget", "make"] + + mac: + deps: ["gnat", "zlib", "wget", "make"] + + LLVM: + description: "Compiler infrastructure used by simulators" + versions: ["18.1", "18.0", "17.0", "16.0"] + type: executable + check: ["llvm-config", "clang"] + version_cmd: + - llvm-config --version + - clang --version + + Verilator: + description: "Fast Verilog/SystemVerilog simulator" + + versions: ["5.020", "5.016", "5.008", "4.228"] + + type: executable + check: verilator + version_cmd: verilator --version + + min_version: "4.210" + recommended_version: "4.210" + + install: + linux: + method: source + repo: "https://github.com/verilator/verilator.git" + deps: + - git + - make + - autoconf + - g++ + - flex + - bison + - libfl-dev + - zlib1g-dev + + build: + - "autoconf" + - "./configure" + - "make -j{jobs}" + - "sudo make install" + + windows: + method: choco + package: verilator + + mac: + method: brew + package: verilator + + Python: + description: "Python runtime" + versions: ["3.13", "3.12", "3.11", "3.10", "3.9"] + type: executable + + check: + - python3 + - python + - py + + version_cmd: + - python3 --version + - python --version + - py --version + + min_version: "3.9" + recommended_version: "3.13.1" + + pip_packages: + - watchdog + - https://github.com/hdl/pyhdlparser/tarball/master + - makerchip-app + - sandpiper-saas + - psutil + - pyqt5 + - matplotlib + + dependencies: + - make + - gnat + - clang + - zlib1g-dev + - libcanberra-gtk-module + - libcanberra-gtk3-module + - libxaw7 + - libxaw7-dev + - llvm-9 + - llvm9-dev diff --git a/python-tool-manager-gui/verilator.py b/python-tool-manager-gui/verilator.py new file mode 100644 index 000000000..cb85c43c9 --- /dev/null +++ b/python-tool-manager-gui/verilator.py @@ -0,0 +1,321 @@ +import os +import re +import shutil +import subprocess +import platform +import tempfile +from datetime import datetime +import logging +import yaml + +BASE_DIR = os.path.dirname(__file__) +TOOLS_FILE = os.path.join(BASE_DIR, "tools.yml") +INSTALL_DETAILS_FILE = os.path.join(BASE_DIR, "install_details.yml") + +# ---------------- LOGGING ---------------- # + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("verilator") + +# ---------------- OS ---------------- # + +def get_os(): + s = platform.system().lower() + if "windows" in s: + return "windows" + if "linux" in s: + return "linux" + if "darwin" in s: + return "mac" + return None + + +def tool_exists(name): + return shutil.which(name) is not None + + +# ---------------- RUN ---------------- # + +def run(cmd, log=logger.info, cwd=None, retry=0, dry_run=False, env=None): + display = cmd if isinstance(cmd, str) else " ".join(cmd) + + if dry_run: + log(f"[DRY RUN] {display}") + return + + for attempt in range(retry + 1): + try: + log(f"> {display}") + + p = subprocess.Popen( + cmd, + shell=isinstance(cmd, str), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + cwd=cwd, + env=env + ) + + if p.stdout: + for line in iter(p.stdout.readline, ""): + log(line.rstrip()) + + p.wait(timeout=300) + + if p.returncode != 0: + raise RuntimeError(f"Command failed: {display}") + + return + + except Exception as e: + if attempt == retry: + raise + log(f"Retrying... ({attempt + 1})") + + +# ---------------- YAML ---------------- # + +def load_cfg(): + with open(TOOLS_FILE) as f: + data = yaml.safe_load(f) or {} + return data.get("tools", {}).get("Verilator") + + +# ---------------- INSTALL DETAILS ---------------- # + +def _load(): + if not os.path.exists(INSTALL_DETAILS_FILE): + return {"important_packages": []} + with open(INSTALL_DETAILS_FILE) as f: + return yaml.safe_load(f) or {"important_packages": []} + + +def _save(d): + with open(INSTALL_DETAILS_FILE, "w") as f: + yaml.safe_dump(d, f, sort_keys=False) + + +def get_install_entry(name): + data = _load() + return next( + (p for p in data.get("important_packages", []) if p["package_name"] == name), + None + ) + + +def get_install_type(name): + entry = get_install_entry(name) + return entry.get("install_type") if entry else "unknown" + + +def _upsert(name, installed, version, path, install_type=None): + data = _load() + pkgs = data.setdefault("important_packages", []) + + entry = next((p for p in pkgs if p.get("package_name") == name), None) + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # preserve install_type if not provided + if not install_type and entry: + install_type = entry.get("install_type") + + payload = { + "package_name": name, + "version": version if installed else "-", + "installed": "Yes" if installed else "No", + "installed_date": now if installed else "-", + "install_directory": path if installed else "-", + "install_type": install_type if installed else "-" + } + + if entry: + entry.update(payload) + else: + pkgs.append(payload) + + _save(data) + + +# ---------------- VERSION ---------------- # + +def detect_version(): + if not tool_exists("verilator"): + return None + try: + out = subprocess.check_output(["verilator", "--version"], text=True) + m = re.search(r"verilator\s+([\d\.]+)", out, re.IGNORECASE) + return m.group(1) if m else None + except subprocess.CalledProcessError: + return None + + +def validate_version(version, cfg): + versions = cfg.get("versions", []) + if version != "latest" and version not in versions: + raise ValueError(f"Invalid version: {version}") + + +# ---------------- INSTALL ---------------- # + +def _check_dependencies(tools): + for t in tools: + if not tool_exists(t): + raise RuntimeError(f"Missing dependency: {t}") + + +def _install_linux(version, cfg, log, dry_run): + linux = cfg["install"]["linux"] + + run("sudo apt-get update", log, dry_run=dry_run) + + deps = linux.get("deps", []) + if deps: + run(["sudo", "apt-get", "install", "-y"] + deps, log, dry_run=dry_run) + + _check_dependencies(["git", "make", "gcc"]) + + repo = linux.get("repo") + + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "verilator") + + run(["git", "clone", repo, src], log, dry_run=dry_run) + + if version.lower() != "latest": + run(["git", "checkout", f"v{version}"], log, cwd=src, dry_run=dry_run) + + env = os.environ.copy() + env["MAKEFLAGS"] = f"-j{os.cpu_count() or 2}" + + for cmd in linux.get("build", []): + run(cmd, log, cwd=src, env=env, dry_run=dry_run) + + +def _install_windows(version, cfg, log, dry_run): + pkg = cfg["install"]["windows"]["package"] + + if version == "latest": + run(f"choco install -y {pkg}", log, dry_run=dry_run) + else: + run(f"choco install -y {pkg} --version={version}", log, dry_run=dry_run) + + +def _install_mac(cfg, log, dry_run): + pkg = cfg["install"]["mac"]["package"] + run(f"brew install {pkg}", log, dry_run=dry_run) + + +def install_verilator(version="latest", log=logger.info, dry_run=False): + log("=== INSTALLING VERILATOR ===") + + cfg = load_cfg() + if not cfg: + raise RuntimeError("Verilator config missing in tools.yml") + + validate_version(version, cfg) + + os_type = get_os() + installed_v = detect_version() + + if installed_v: + if version == "latest" or installed_v == version: + log(f"Already installed ({installed_v})") + + _upsert( + "verilator", + True, + installed_v, + shutil.which("verilator"), + get_install_type("verilator") + ) + return + else: + log(f"Different version detected ({installed_v}) → reinstalling") + uninstall_verilator(log) + + if os_type == "linux": + _install_linux(version, cfg, log, dry_run) + install_type = "source" + + elif os_type == "windows": + _install_windows(version, cfg, log, dry_run) + install_type = "package" + + elif os_type == "mac": + _install_mac(cfg, log, dry_run) + install_type = "package" + + else: + raise RuntimeError("Unsupported OS") + + v = detect_version() + if not v: + raise RuntimeError("Installation failed: version not detected") + + _upsert("verilator", True, v, shutil.which("verilator"), install_type) + + log(f"Installed Verilator {v}") + + +# ---------------- UNINSTALL ---------------- # + +def _cleanup(log): + paths = [ + os.path.expanduser("~/.cache/verilator"), + os.path.expanduser("~/.verilator"), + ] + for p in paths: + if os.path.exists(p): + shutil.rmtree(p, ignore_errors=True) + log(f"Removed {p}") + + +def uninstall_verilator(log=logger.info): + log("=== UNINSTALLING VERILATOR ===") + + cfg = load_cfg() + os_type = get_os() + + if not tool_exists(cfg["check"]): + log("Not installed") + _upsert("verilator", False, "-", "-", "-") + return + + install_type = get_install_type("verilator") + + try: + if os_type == "linux": + if install_type == "source": + log("Removing source installation safely") + + paths = [ + "/usr/local/bin/verilator", + "/usr/local/share/verilator", + "/usr/local/include/verilator", + ] + + for p in paths: + if os.path.exists(p): + run(["sudo", "rm", "-rf", p], log) + + else: + run(["sudo", "apt-get", "purge", "-y", "verilator"], log) + run(["sudo", "apt-get", "autoremove", "-y"], log) + + elif os_type == "windows": + run("choco uninstall -y verilator", log) + + elif os_type == "mac": + run("brew uninstall verilator", log) + + _cleanup(log) + + _upsert("verilator", False, "-", "-", "-") + + log("=== VERILATOR FULLY REMOVED ===") + + except Exception as e: + log(f"[ERROR] {e}") + raise \ No newline at end of file diff --git a/python-tool-manager-gui/version.py b/python-tool-manager-gui/version.py new file mode 100644 index 000000000..cf22266f4 --- /dev/null +++ b/python-tool-manager-gui/version.py @@ -0,0 +1,114 @@ +import subprocess +import re +from packaging import version as vparse + + +# ----------- VERSION UTILS ----------- + +def parse_version(v): + return tuple(map(int, v.split("."))) + + +def max_version(versions): + if not versions: + return None + + parsed = [] + for item in versions: + if not isinstance(item, str): + continue + + s = item.strip() + if not s: + continue + + try: + parsed.append((vparse.parse(s), s)) + except Exception: + continue + + if not parsed: + return None + + parsed.sort(key=lambda t: t[0], reverse=True) + return parsed[0][1] + + +def compare_versions(installed, target): + try: + i = vparse.parse(installed) + t = vparse.parse(target) + + if i < t: + return -1 + elif i > t: + return 1 + else: + return 0 + except Exception: + return None + + +# ----------- VERSION DETECTION ----------- + +def get_version(commands): + """ + Robust version detection: + Supports: + - 46 + - 46.1 + - 3.3.10 + - mixed stdout/stderr + """ + + if not commands: + return None + + if isinstance(commands, str): + commands = [commands] + + for cmd in commands: + try: + result = subprocess.run( + cmd.split(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + output = (result.stdout or "") + (result.stderr or "") + if not output: + continue + + # 🔥 supports ALL formats + match = re.search(r"(\d+(\.\d+)+|\d+)", output) + if match: + return match.group(1) + + except Exception: + continue + + return None + + +# ----------- STATUS CHECK ----------- + +def check_tool_version(tool_cfg): + installed = get_version(tool_cfg.get("version_cmd")) + + if not installed: + return "not_installed" + + min_v = tool_cfg.get("min_version") + rec_v = tool_cfg.get("recommended_version") + + if min_v and compare_versions(installed, min_v) == -1: + return "too_old" + + if rec_v: + cmp = compare_versions(installed, rec_v) + if cmp == -1: + return "outdated" + return "up_to_date" + + return "up_to_date" \ No newline at end of file