prole/knoe/ui/screens/dependencies.py
chrisfu 4ee2b259c9 Checkpoint: rename installer to knoe + harden db build context
- Add build-context helper to copy Docker context safely (ignore runtime data, keep symlinks)

- Update UI and core actions to use ~/.prole/build and shared copy helper

- Add/adjust tests and scripts; introduce knoe ops helpers and update manifests

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 01:45:21 -07:00

636 lines
24 KiB
Python

"""Dependency scanning, summary and per-dependency install screens."""
import re
import threading
import time
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from knoe import config as inst_config
from knoe import screen as ui
class DependenciesScreenMixin:
"""Dependency scanning, summary and per-dependency install screens."""
def _render_deps_summary_page(self):
self._render_title("Dependencies", y=40)
y = 100
# Draw each dependency row: status dot (blank initially), name, info
row_gap = 36
self.dep_status_items = {} # Store canvas IDs to update later
self.dep_install_buttons = {} # Store button window items
for dep in self.dependencies:
indent = 0
if dep.get("parent"):
indent = 24
left = 56 + indent
text_x = left + 28
# blank box (outline only initially)
dot = ui.canvas_rectangle(
self, left, y, left + 18, y + 18, outline="#6e6e73", width=2
)
self._canvas_items.append(dot)
# name
self._canvas_items.append(
ui.canvas_text(
self,
text_x,
y - 2,
dep["name"],
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
# info placeholder
info = ui.canvas_text(
self,
text_x + 180,
y,
"Checking...",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
self._canvas_items.append(info)
self.dep_status_items[dep["id"]] = {
"dot": dot,
"info": info,
"y": y,
"left": left,
}
y += row_gap
# Message line
msg_y = y + 30
self.deps_msg_item = ui.canvas_text(
self,
48,
msg_y,
"Scanning system...",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
self._canvas_items.append(self.deps_msg_item)
# Start async check
threading.Thread(target=self._run_delayed_deps_check, daemon=True).start()
def _render_dependency_page(self, dep):
self._render_title(dep["name"], y=40)
self._render_paragraph(dep["description"], y=88)
# Status
ok, location, version = self.get_dep_info(dep)
y = 180
left = 56
if ok:
self._canvas_items.append(
ui.canvas_oval(
self, left, y, left + 18, y + 18, fill="#34c759", outline=""
)
)
self._canvas_items.append(
ui.canvas_text(
self,
left + 32,
y - 2,
"Installed",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
if location:
self._canvas_items.append(
ui.canvas_text(
self,
left + 32,
y + 32,
f"Location: {location}",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
)
if version:
ver = self.normalize_version(version)
self._canvas_items.append(
ui.canvas_text(
self,
left + 32,
y + 56,
f"Version: {ver}",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
)
else:
# Check if we should auto-install
install_cmd = dep.get("install_cmd")
if install_cmd and self._installing_dep_id != dep["id"]:
# Launch directly into installation
self._canvas_items.append(
ui.canvas_oval(
self, left, y, left + 18, y + 18, fill="#ffd60a", outline=""
)
)
self._canvas_items.append(
ui.canvas_text(
self,
left + 32,
y - 2,
"Installing...",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
# Trigger console-based install for brew/pip
if "brew install" in install_cmd or "pip install" in install_cmd:
self.root.after(500, lambda: self._install_dep_in_console(dep))
else:
self.root.after(
500, lambda: self.open_terminal_with_command(install_cmd)
)
elif self._installing_dep_id == dep["id"]:
self._canvas_items.append(
ui.canvas_oval(
self, left, y, left + 18, y + 18, fill="#ffd60a", outline=""
)
)
self._canvas_items.append(
ui.canvas_text(
self,
left + 32,
y - 2,
"Installing...",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
else:
self._canvas_items.append(
ui.canvas_oval(
self, left, y, left + 18, y + 18, fill="#ff9f0a", outline=""
)
)
self._canvas_items.append(
ui.canvas_text(
self,
left + 32,
y - 2,
"Not installed",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
# Click to install link (if available)
if install_cmd:
link_y = y + 40
link_text = ui.render_link(
self, left + 32, link_y, "Click to install"
)
self._canvas_items.append(link_text)
self.bg_canvas.config(cursor="hand2")
def _on_click(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link_text)
if (
bbox
and bbox[0] <= ex <= bbox[2]
and bbox[1] <= ey <= bbox[3]
):
self.open_terminal_with_command(install_cmd)
self.bg_canvas.bind("<Button-1>", _on_click)
def _install_dep_in_console(self, dep):
"""Run dependency installation in the embedded console."""
if self._installing_dep_id == dep["id"]:
return
try:
self._action_flags[f"dependencies.{dep['id']}.install"] = True
except Exception:
pass
install_cmd = dep.get("install_cmd")
if not install_cmd:
return
self._installing_dep_id = dep["id"]
# Prepare log file
logs_dir = self._resolve_env_dir("PROLE_LOGS", "logs")
try:
logs_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
ts = time.strftime("%Y%m%d-%H%M%S")
log_path = logs_dir / f"install-{dep['id']}-{ts}.log"
# Show console
self._ensure_console_overlay(radio_bottom_y=160)
self._console_text.configure(state="normal")
self._console_text.delete("1.0", tk.END)
self._console_text.insert("end", f"Starting installation of {dep['name']}...\n")
self._console_text.insert("end", f"Command: {install_cmd}\n\n")
self._console_text.configure(state="disabled")
def on_done(rc):
self._installing_dep_id = None
if rc == 0:
self._append_console(f"\nSuccessfully installed {dep['name']}.\n")
# Refresh status and re-render page
self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}"))
else:
self._append_console(f"\nInstallation failed with exit code {rc}.\n")
# Enable next button so user can retry or proceed if they fixed it manually
try:
self.next_button.configure(state="normal", text="Next")
except Exception:
pass
self._run_in_console(install_cmd, str(log_path), on_complete=on_done)
# ---------------- Initialize Screen Handlers ----------------
def _run_delayed_deps_check(self):
results = {}
any_missing = False
for dep in self.dependencies:
ok, location, version = self.get_dep_info(dep)
results[dep["id"]] = (ok, location, version)
# Update UI for this item
def update_item(did=dep["id"], ok=ok, version=version):
items = self.dep_status_items.get(did)
if not items:
return
# Replace box with colored dot/check
self.bg_canvas.delete(items["dot"])
left = items.get("left", 56)
y = items["y"]
if ok:
items["dot"] = ui.canvas_oval(
self, left, y, left + 18, y + 18, fill="#34c759", outline=""
)
# Add a small white checkmark inside the green dot
self.bg_canvas.create_line(
left + 5,
y + 9,
left + 8,
y + 12,
fill="white",
width=2,
tags=f"page_item_{self.pages[self.page_index][0]}",
)
self.bg_canvas.create_line(
left + 8,
y + 12,
left + 13,
y + 6,
fill="white",
width=2,
tags=f"page_item_{self.pages[self.page_index][0]}",
)
else:
items["dot"] = ui.canvas_oval(
self, left, y, left + 18, y + 18, fill="#ff3b30", outline=""
)
# Add "Click to install" button
btn = tk.Button(
self.bg_canvas,
text="Install",
command=lambda d=did: self.show_page(f"dep_{d}"),
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=8,
pady=2,
)
btn_window = self.bg_canvas.create_window(
left + 350, y, window=btn, anchor="nw"
)
self._overlay_widgets.append(btn)
self._canvas_items.append(btn_window)
self.dep_install_buttons[did] = btn_window
info_text = (
self.normalize_version(version)
if ok and version
else ("Not installed" if not ok else "")
)
self.bg_canvas.itemconfig(items["info"], text=info_text)
self.safe_after(update_item)
time.sleep(0.1) # small delay to show it checking one by one
def final_update():
missing = [
d["name"]
for d in self.dependencies
if not results.get(d["id"], (False, None, None))[0]
]
if missing:
msg = f"Preparing to install ... {', '.join(missing)}"
else:
msg = "All dependencies installed."
try:
if (
self.bg_canvas.winfo_exists()
and self.deps_msg_item in self.bg_canvas.find_all()
):
self.bg_canvas.itemconfig(self.deps_msg_item, text=msg)
except (tk.TclError, RuntimeError):
pass
self.update_footer()
self.safe_after(final_update)
def _create_page_dependencies_summary(self):
f = self._page_container()
ttk.Label(f, text="Dependencies", style="Title.TLabel").pack(
anchor="w", padx=24, pady=(24, 6)
)
self.deps_container = ttk.Frame(f)
self.deps_container.pack(fill="both", expand=True, padx=16, pady=8)
self.dep_status = {}
for dep in self.dependencies:
row = ttk.Frame(self.deps_container)
row.pack(fill="x", pady=6)
indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0)
indicator.pack(side="left", padx=8)
name = ttk.Label(row, text=dep["name"], style="Body.TLabel")
name.pack(side="left")
info = ttk.Label(row, text="", style="Dim.TLabel")
info.pack(side="left", padx=10)
self.dep_status[dep["id"]] = {"canvas": indicator, "info": info, "dep": dep}
# Verify all checkbox (controls navigation behavior)
chk = ttk.Checkbutton(
f,
text="Verify all dependencies",
variable=self.verify_mode,
command=self.update_footer,
)
chk.pack(anchor="w", padx=24, pady=(4, 0))
# Message label must be created before refresh to avoid AttributeError
self.deps_msg = ttk.Label(f, text="", style="Dim.TLabel")
self.deps_msg.pack(anchor="w", padx=24, pady=(8, 8))
# Now we can safely populate the UI. Start async scan to avoid startup delay
self.deps_msg.configure(text="Checking dependencies...")
self.start_dependency_scan()
# self._register_page('deps_summary', f)
def _create_page_dependency(self, dep):
f = self._page_container()
ttk.Label(f, text=dep["name"], style="Title.TLabel").pack(
anchor="w", padx=24, pady=(24, 6)
)
ttk.Label(
f,
text=dep["description"],
style="Body.TLabel",
wraplength=800,
justify="left",
).pack(anchor="w", padx=24)
status_var = tk.StringVar(value="Checking...")
loc_var = tk.StringVar(value="")
ver_var = tk.StringVar(value="")
info_frame = ttk.Frame(f)
info_frame.pack(fill="x", padx=24, pady=12)
# Pretty status row with an icon
status_row = ttk.Frame(info_frame)
status_row.pack(anchor="w", fill="x")
status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0)
status_icon.pack(side="left", padx=(0, 8), pady=(2, 0))
ttk.Label(status_row, textvariable=status_var, style="Body.TLabel").pack(
side="left"
)
# Detail rows (hidden when not installed)
loc_label = ttk.Label(info_frame, textvariable=loc_var, style="Dim.TLabel")
ver_label = ttk.Label(info_frame, textvariable=ver_var, style="Dim.TLabel")
loc_label.pack(anchor="w")
ver_label.pack(anchor="w")
link = ttk.Label(
f,
text="Click to install",
foreground="#0a84ff",
cursor="hand2",
style="Body.TLabel",
)
link.pack(anchor="w", padx=24, pady=(12, 0))
def _draw_status_icon(canvas, status):
ui.canvas_clear(canvas)
if status == "installed":
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill="#34c759", outline="")
ui.canvas_line_on(canvas, 4, 9, 8, 13, fill="white", width=2)
ui.canvas_line_on(canvas, 8, 13, 15, 5, fill="white", width=2)
elif status == "missing":
# Friendly warning dot with exclamation
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill="#ff9f0a", outline="")
ui.canvas_line_on(canvas, 9, 5, 9, 11, fill="white", width=2)
ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill="white", outline="white")
def check_then_update():
ok, location, version = self.get_dep_info(dep)
if ok:
status_var.set("Installed")
_draw_status_icon(status_icon, "installed")
loc_var.set(f'Location: {location or ""}')
norm_ver = self.normalize_version(version) if version else None
ver_var.set(f'Version: {norm_ver or ""}')
# Ensure details visible
try:
loc_label.pack_configure()
ver_label.pack_configure()
link.pack_forget()
except Exception:
pass
else:
status_var.set("Not installed")
_draw_status_icon(status_icon, "missing")
# Hide undefined details instead of showing dashes
try:
loc_label.pack_forget()
ver_label.pack_forget()
except Exception:
pass
try:
# Keep install link visible if we have a command
if dep.get("install_cmd") and not link.winfo_ismapped():
link.pack(anchor="w", padx=24, pady=(12, 0))
except Exception:
pass
self.root.after(50, check_then_update)
def do_install(event=None):
self.open_terminal_with_command(dep.get("install_cmd"))
if dep.get("install_cmd"):
link.bind("<Button-1>", do_install)
else:
try:
link.pack_forget()
except Exception:
pass
# self._register_page(f'dep_{dep["id"]}', f)
def _dep_navigation_sequence(self, force_all: bool = False):
"""Return a list of dependency page ids to traverse next.
- If force_all is True or verify_mode is True: include all dep pages in defined order.
- Else: include only missing dependency pages based on current checks.
"""
if force_all or self.verify_mode.get():
res = [f'dep_{d["id"]}' for d in self.dependencies]
print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}")
return res
seq = []
for d in self.dependencies:
ok, _, _ = self.get_dep_info(d)
if not ok:
seq.append(f'dep_{d["id"]}')
print(f"[DEBUG] _dep_navigation_sequence: {seq}")
return seq
def start_dependency_scan(self):
"""Scan dependencies in a background thread to avoid blocking UI startup."""
if self.validation_running:
return
self.validation_running = True
# Hold incremental results so we can update the status line and icons progressively
self._dep_scan_results = {}
def worker():
for dep in self.dependencies:
try:
ok, location, version = self.get_dep_info(dep)
except Exception:
ok, location, version = False, None, None
did = dep["id"]
# Save and apply incrementally
self._dep_scan_results[did] = (ok, location, version)
self.safe_after(
lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(
d, o, l, v
)
)
try:
time.sleep(0.02)
except Exception:
pass
# After all are processed, finalize pass to unify any remaining labels
self.safe_after(
lambda: self._apply_dependency_scan(dict(self._dep_scan_results))
)
t = threading.Thread(target=worker, daemon=True)
self.validation_thread = t
t.start()
def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version):
"""Update the dependencies page row and message as each check completes."""
if not hasattr(self, "dep_status"):
return
slot = self.dep_status.get(dep_id)
if not slot:
return
# Update dot icon and info text for this row
self._draw_status(slot["canvas"], "success" if ok else "error")
if ok:
norm_ver = self.normalize_version(version) if version else ""
slot["info"].configure(text=(norm_ver or ""))
else:
slot["info"].configure(text="Not installed")
# Update the message line with current missing list
if hasattr(self, "_dep_scan_results"):
missing = [
self.dep_status[d]["dep"]["name"]
for d, res in self._dep_scan_results.items()
if not res[0] and d in self.dep_status
]
if hasattr(self, "deps_msg") and self.deps_msg is not None:
if missing:
self.deps_msg.configure(
text=f"Preparing to install ... {', '.join(missing)}"
)
else:
self.deps_msg.configure(text="All dependencies installed.")
# Footer may need to react if verify mode is enabled
self.update_footer()
# Update prole.cfg data
try:
status_str = f"installed (version {version})" if ok else "not installed"
self.prole_cfg_data["Dependencies"][dep_id] = status_str
self._save_prole_cfg()
except Exception:
pass
def _apply_dependency_scan(self, results: dict):
"""Apply scan results to the summary UI and footer labels."""
self.validation_running = False
any_missing = False
for did, slot in self.dep_status.items():
ok, location, version = results.get(did, (False, None, None))
self._draw_status(slot["canvas"], "success" if ok else "error")
if ok:
norm_ver = self.normalize_version(version) if version else ""
slot["info"].configure(text=(norm_ver or ""))
else:
any_missing = True
slot["info"].configure(text="Not installed")
has_msg = hasattr(self, "deps_msg") and self.deps_msg is not None
if any_missing:
missing = [
slot["dep"]["name"]
for did, slot in self.dep_status.items()
if not results.get(did, (False, None, None))[0]
]
if missing and has_msg:
self.deps_msg.configure(
text=f"Preparing to install ... {', '.join(missing)}"
)
else:
if has_msg:
self.deps_msg.configure(text="All dependencies installed.")
self.update_footer()
def refresh_dependencies_ui(self):
"""Deprecated synchronous refresh retained for compatibility.
Prefer start_dependency_scan -> _apply_dependency_scan.
"""
self.start_dependency_scan()
def all_dependencies_installed(self):
for dep in self.dependencies:
ok, _, _ = self.get_dep_info(dep)
if not ok:
print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING")
return False
print("[DEBUG] all_dependencies_installed: YES (all OK)")
return True
def get_dep_info(self, dep):
"""Delegate dependency probing to knoe.config.get_dep_info."""
return inst_config.get_dep_info(dep)
def normalize_version(self, text: str) -> str:
"""Normalize versions via knoe.config.normalize_version."""
return inst_config.normalize_version(text)