SXXXXXXX_DependencyAnalyzer/dependencyanalyzer/gui.py

470 lines
25 KiB
Python

# dependencyanalyzer/gui.py
import logging
import re
import sys
import textwrap
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from .core import analyzer, package_manager, project_normalizer
# --- Import Version Info FOR THE WRAPPER ITSELF ---
try:
from dependencyanalyzer import _version as wrapper_version
WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})"
WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}"
except ImportError:
WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)"
WRAPPER_BUILD_INFO = "Wrapper build time unknown"
class TextHandler(logging.Handler):
"""A logging handler that writes records to a Tkinter Text widget."""
def __init__(self, text_widget: tk.Text):
super().__init__()
self.text_widget = text_widget
self.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
def emit(self, record: logging.LogRecord):
msg = self.format(record)
def append_message():
if self.text_widget.winfo_exists():
self.text_widget.config(state=tk.NORMAL)
self.text_widget.insert(tk.END, msg + "\n")
self.text_widget.see(tk.END)
self.text_widget.config(state=tk.DISABLED)
self.text_widget.update_idletasks()
try:
self.text_widget.after(0, append_message)
except RuntimeError:
print(f"Log error (widget destroyed?): {msg}", file=sys.stderr)
class DependencyAnalyzerApp(tk.Frame):
"""Tkinter GUI for Python dependency analysis and project normalization."""
def __init__(self, master: Optional[tk.Tk] = None):
super().__init__(master)
self.master = master
if self.master:
self.master.title(f"Python Project Tools - {WRAPPER_APP_VERSION_STRING}")
self.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.selected_repository_path: Optional[Path] = None
self.std_lib_deps_info: analyzer.DependencyInfo = {}
self.external_deps_info: analyzer.DependencyInfo = {}
self.extracted_dependencies_names: Set[str] = set()
self.requirements_file_path: Optional[Path] = None
# --- NEW: Storage for normalizer step messages ---
self.normalizer_step_messages: List[str] = []
self._create_widgets()
self._setup_logging_handler()
self._update_button_states()
def _create_widgets(self) -> None:
"""Creates and lays out the GUI widgets."""
repo_frame = ttk.LabelFrame(self, text="1. Select Project Repository", padding=(10, 5))
repo_frame.pack(fill=tk.X, pady=(0, 10))
self.select_repo_button = ttk.Button(
repo_frame, text="Select Folder", command=self._select_repository
)
self.select_repo_button.pack(side=tk.LEFT, padx=5, pady=5)
self.repo_path_label = ttk.Label(repo_frame, text="No repository selected.")
self.repo_path_label.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5, pady=5)
self.notebook = ttk.Notebook(self)
self.notebook.pack(fill=tk.BOTH, expand=True, pady=5)
self.analyzer_tab = ttk.Frame(self.notebook, padding=(10, 10))
self.normalizer_tab = ttk.Frame(self.notebook, padding=(10, 10))
self.notebook.add(self.analyzer_tab, text="Dependency Analyzer")
self.notebook.add(self.normalizer_tab, text="Project Normalizer")
self._create_analyzer_tab_widgets(self.analyzer_tab)
self._create_normalizer_tab_widgets(self.normalizer_tab)
log_frame = ttk.LabelFrame(self, text="Log Messages", padding=(10, 5))
log_frame.pack(fill=tk.X, pady=5)
self.log_text = tk.Text(
log_frame, height=8, state=tk.DISABLED, wrap=tk.WORD,
relief=tk.SUNKEN, borderwidth=1,
)
log_scrollbar = ttk.Scrollbar(log_frame, command=self.log_text.yview)
self.log_text.configure(yscrollcommand=log_scrollbar.set)
log_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.log_text.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5, pady=5)
def _create_analyzer_tab_widgets(self, parent_frame: ttk.Frame) -> None:
"""Creates all widgets for the Dependency Analyzer tab."""
# This function's content remains the same, so it's complete.
analysis_frame = ttk.LabelFrame(parent_frame, text="2. Analysis & Requirements", padding=(10, 5))
analysis_frame.pack(fill=tk.X, pady=5)
self.analyze_button = ttk.Button(
analysis_frame, text="Analyze & Generate requirements.txt", command=self._analyze_and_generate_reqs_threaded,
)
self.analyze_button.pack(side=tk.LEFT, padx=5, pady=5)
paned_window = ttk.PanedWindow(parent_frame, orient=tk.VERTICAL)
paned_window.pack(fill=tk.BOTH, expand=True, pady=5)
modules_frame = ttk.LabelFrame(paned_window, text="3. External Dependencies Found", padding=(10, 5))
paned_window.add(modules_frame, weight=1)
self.modules_tree = ttk.Treeview(modules_frame, columns=("module_name",), show="headings")
self.modules_tree.heading("module_name", text="External Module Name")
self.modules_tree.column("module_name", stretch=tk.YES)
modules_scrollbar = ttk.Scrollbar(modules_frame, orient="vertical", command=self.modules_tree.yview)
self.modules_tree.configure(yscrollcommand=modules_scrollbar.set)
modules_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.modules_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
actions_pane_frame = ttk.Frame(paned_window)
paned_window.add(actions_pane_frame, weight=2)
download_frame = ttk.LabelFrame(actions_pane_frame, text="4. Create Offline Installer", padding=(10, 5))
download_frame.pack(fill=tk.X, pady=5)
self.download_button = ttk.Button(
download_frame, text="Download Packages", command=self._download_packages_threaded
)
self.download_button.pack(side=tk.LEFT, padx=5, pady=5)
self.create_scripts_button = ttk.Button(
download_frame, text="Create Install Scripts", command=self._create_install_scripts_threaded
)
self.create_scripts_button.pack(side=tk.LEFT, padx=5, pady=5)
compare_frame = ttk.LabelFrame(actions_pane_frame, text="5. System Package Comparison", padding=(10, 5))
compare_frame.pack(fill=tk.BOTH, expand=True, pady=5)
self.compare_button = ttk.Button(
compare_frame, text="Compare with Installed Packages", command=self._compare_packages_threaded
)
self.compare_button.pack(side=tk.TOP, anchor=tk.NW, padx=5, pady=5)
tree_frame = ttk.Frame(compare_frame)
tree_frame.pack(fill=tk.BOTH, expand=True, pady=5)
self.comparison_tree = ttk.Treeview(
tree_frame, columns=("package", "required", "installed", "status"), show="headings"
)
self.comparison_tree.heading("package", text="Package")
self.comparison_tree.column("package", width=150, stretch=tk.NO, anchor=tk.W)
self.comparison_tree.heading("required", text="Required Version")
self.comparison_tree.column("required", width=150, stretch=tk.NO, anchor=tk.W)
self.comparison_tree.heading("installed", text="Installed Version")
self.comparison_tree.column("installed", width=150, stretch=tk.NO, anchor=tk.W)
self.comparison_tree.heading("status", text="Status")
self.comparison_tree.column("status", width=120, stretch=tk.YES, anchor=tk.W)
compare_scrollbar_y = ttk.Scrollbar(tree_frame, orient="vertical", command=self.comparison_tree.yview)
self.comparison_tree.configure(yscrollcommand=compare_scrollbar_y.set)
compare_scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y)
self.comparison_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self.update_system_button = ttk.Button(
compare_frame, text="Update Selected Packages in System", command=self._update_system_packages_threaded
)
self.update_system_button.pack(side=tk.TOP, anchor=tk.NW, padx=5, pady=(5,0))
def _create_normalizer_tab_widgets(self, parent_frame: ttk.Frame) -> None:
"""Creates widgets for the Project Normalizer tab with the new design."""
action_frame = ttk.LabelFrame(parent_frame, text="2. Normalization Actions", padding=(10, 5))
action_frame.pack(fill=tk.X, pady=5)
self.normalize_button = ttk.Button(
action_frame, text="Normalize Project", command=self._normalize_project_threaded,
)
self.normalize_button.pack(side=tk.LEFT, padx=5, pady=5)
# Main container for the results section
results_frame = ttk.LabelFrame(parent_frame, text="3. Normalization Results", padding=(10, 5))
results_frame.pack(fill=tk.BOTH, expand=True, pady=5)
# Paned window to allow resizing between steps list and details
results_pane = ttk.PanedWindow(results_frame, orient=tk.HORIZONTAL)
results_pane.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Left pane: List of steps
steps_list_frame = ttk.Frame(results_pane)
results_pane.add(steps_list_frame, weight=1) # Smaller weight
self.normalizer_steps_list = tk.Listbox(
steps_list_frame, selectmode=tk.SINGLE, exportselection=False,
)
self.normalizer_steps_list.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
steps_scrollbar = ttk.Scrollbar(
steps_list_frame, orient="vertical", command=self.normalizer_steps_list.yview
)
steps_scrollbar.pack(fill=tk.Y, side=tk.RIGHT)
self.normalizer_steps_list.config(yscrollcommand=steps_scrollbar.set)
# Event binding for when a step is selected
self.normalizer_steps_list.bind('<<ListboxSelect>>', self._on_normalizer_step_select)
# Right pane: Details text box
details_text_frame = ttk.Frame(results_pane)
results_pane.add(details_text_frame, weight=3) # Larger weight
self.normalizer_details_text = tk.Text(
details_text_frame, wrap=tk.WORD, state=tk.DISABLED, relief=tk.SUNKEN, borderwidth=1,
)
self.normalizer_details_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
details_scrollbar = ttk.Scrollbar(
details_text_frame, orient="vertical", command=self.normalizer_details_text.yview
)
details_scrollbar.pack(fill=tk.Y, side=tk.RIGHT)
self.normalizer_details_text.config(yscrollcommand=details_scrollbar.set)
def _setup_logging_handler(self):
text_handler = TextHandler(self.log_text)
text_handler.setLevel(logging.INFO)
root_logger = logging.getLogger()
if not any(isinstance(h, TextHandler) for h in root_logger.handlers):
root_logger.addHandler(text_handler)
def _update_button_states(self) -> None:
repo_selected = bool(self.selected_repository_path)
try:
self.analyze_button.config(state=tk.NORMAL if repo_selected else tk.DISABLED)
req_file_exists = self.requirements_file_path and self.requirements_file_path.exists()
self.download_button.config(state=tk.NORMAL if req_file_exists else tk.DISABLED)
self.create_scripts_button.config(state=tk.NORMAL if req_file_exists else tk.DISABLED)
self.compare_button.config(state=tk.NORMAL if req_file_exists else tk.DISABLED)
can_update = bool(self.comparison_tree.get_children()) and req_file_exists
self.update_system_button.config(state=tk.NORMAL if can_update else tk.DISABLED)
self.normalize_button.config(state=tk.NORMAL if repo_selected else tk.DISABLED)
except tk.TclError:
pass
def _select_repository(self) -> None:
path = filedialog.askdirectory(title="Select Project Folder")
if not path:
logging.info("Project selection cancelled.")
return
self.selected_repository_path = Path(path)
self.repo_path_label.config(text=str(self.selected_repository_path))
logging.info(f"Project selected: {self.selected_repository_path}")
self.std_lib_deps_info, self.external_deps_info = {}, {}
self.extracted_dependencies_names, self.requirements_file_path = set(), None
self.modules_tree.delete(*self.modules_tree.get_children())
self.comparison_tree.delete(*self.comparison_tree.get_children())
self.normalizer_steps_list.delete(0, tk.END)
self.normalizer_details_text.config(state=tk.NORMAL)
self.normalizer_details_text.delete('1.0', tk.END)
self.normalizer_details_text.config(state=tk.DISABLED)
self._update_button_states()
status = project_normalizer.check_project_status(self.selected_repository_path)
if not all(status.values()):
if messagebox.askyesno("Project Setup Incomplete",
"This project appears to be missing a standard setup "
"(e.g., virtual environment, pyproject.toml).\n\n"
"Switch to the 'Project Normalizer' tab to create them?"):
self.notebook.select(1)
def _run_long_task_threaded(self, task_function: Callable[..., Any], *args_for_task: Any,
callback_success: Optional[Callable[[Any], None]] = None) -> None:
def task_wrapper():
try:
result = task_function(*args_for_task)
if callback_success and self.winfo_exists():
self.master.after(0, lambda: callback_success(result))
except Exception as e:
logging.error(f"Error in '{task_function.__name__}': {e}")
logging.exception(f"Full traceback for task {task_function.__name__}:")
finally:
if self.winfo_exists():
self.master.after(0, self._temporarily_disable_buttons, False)
self.master.after(10, self._update_button_states)
self._temporarily_disable_buttons(True)
thread = threading.Thread(target=task_wrapper, daemon=True)
logging.info(f"Starting background task: {task_function.__name__}...")
thread.start()
def _temporarily_disable_buttons(self, disable: bool = True) -> None:
state_to_set = tk.DISABLED if disable else tk.NORMAL
buttons = [self.select_repo_button, self.analyze_button, self.download_button,
self.create_scripts_button, self.compare_button, self.update_system_button,
self.normalize_button]
for button in buttons:
if hasattr(button, 'winfo_exists') and button.winfo_exists():
try: button.config(state=state_to_set)
except tk.TclError: pass
# --- Methods for "Dependency Analyzer" Tab ---
# All methods for the first tab are complete and unchanged.
def _analyze_and_generate_reqs_threaded(self): self._common_threaded_task_call(self._perform_analysis_and_generation, self._analysis_and_generation_callback)
def _perform_analysis_and_generation(self):
if not self.selected_repository_path: raise ValueError("Repo path not set.")
repo_path, scan_path = self.selected_repository_path, self.selected_repository_path
potential_sub = repo_path / repo_path.name.lower()
if potential_sub.is_dir(): scan_path = potential_sub
std_lib, ext = analyzer.find_project_modules_and_dependencies(repo_path, scan_path)
req_file = package_manager.generate_requirements_file(repo_path, ext, std_lib)
return req_file, std_lib, ext
def _analysis_and_generation_callback(self, result):
req_file, std, ext = result
self.requirements_file_path, self.std_lib_deps_info, self.external_deps_info = req_file, std, ext
self.extracted_dependencies_names = set(ext.keys())
logging.info(f"Analysis complete. File created: {self.requirements_file_path}")
self._populate_modules_tree()
self._update_button_states()
def _populate_modules_tree(self):
self.modules_tree.delete(*self.modules_tree.get_children())
for name in sorted(list(self.extracted_dependencies_names)): self.modules_tree.insert("", tk.END, values=(name,))
def _download_packages_threaded(self): self._common_threaded_task_call(package_manager.download_packages, self._download_packages_callback, self.selected_repository_path, self.requirements_file_path)
def _download_packages_callback(self, result): self._handle_simple_callback_result("Download Task", result)
def _create_install_scripts_threaded(self): self._common_threaded_task_call(package_manager.create_install_scripts, self._create_install_scripts_callback, self.selected_repository_path, self.requirements_file_path)
def _create_install_scripts_callback(self, result): self._handle_simple_callback_result("Script Creation Task", result)
def _compare_packages_threaded(self): self._common_threaded_task_call(self._get_comparison_data, self._compare_packages_callback)
def _get_comparison_data(self):
if not self.requirements_file_path: raise ValueError("Req file not set.")
installed = package_manager.get_installed_packages()
return package_manager.compare_requirements_with_installed(self.requirements_file_path, installed)
def _compare_packages_callback(self, results):
self.comparison_tree.delete(*self.comparison_tree.get_children())
for item in results: self.comparison_tree.insert("", tk.END, values=tuple(item.values()), tags=(item["status"],))
logging.info("Comparison results displayed.")
self._update_button_states()
def _update_system_packages_threaded(self):
selected = self.comparison_tree.selection()
if not selected: messagebox.showwarning("No Selection", "No packages selected."); return
to_update = [self.comparison_tree.item(i, "values")[0] for i in selected if self.comparison_tree.item(i, "values")[-1] != "OK"]
if not to_update: messagebox.showinfo("No Action", "Selected packages are up to date."); return
if messagebox.askyesno("Confirm Update", f"Update in system:\n\n- {', '.join(to_update)}\n\nProceed?"):
self._common_threaded_task_call(package_manager.update_system_packages, self._update_system_packages_callback, to_update, self.selected_repository_path)
def _update_system_packages_callback(self, result):
self._handle_simple_callback_result("Update Task", result)
if result[0]: logging.info("Re-comparing after update..."); self._compare_packages_threaded()
def _common_threaded_task_call(self, task, cb, *args):
if not self.selected_repository_path: messagebox.showwarning("Warning", "Select a project first."); return
self._run_long_task_threaded(task, *args, callback_success=cb)
def _handle_simple_callback_result(self, task_name, result):
success, message = result
logging.log(logging.INFO if success else logging.ERROR, f"{task_name}: {message}")
if not success: messagebox.showerror(f"{task_name} Failed", message)
# --- Methods for "Project Normalizer" Tab ---
# --- Methods for "Project Normalizer" Tab ---
def _normalize_project_threaded(self) -> None:
"""Starts the project normalization process in a background thread."""
if not self.selected_repository_path:
messagebox.showwarning("Warning", "Please select a project repository first.")
return
# Clear previous results before starting
self.normalizer_steps_list.delete(0, tk.END)
self.normalizer_details_text.config(state=tk.NORMAL)
self.normalizer_details_text.delete('1.0', tk.END)
self.normalizer_details_text.config(state=tk.DISABLED)
self.normalizer_step_messages.clear()
# The backend function no longer returns a value.
# It reports progress via the callback.
self._run_long_task_threaded(
project_normalizer.normalize_project,
self.selected_repository_path,
self._update_normalizer_progress # Pass the progress callback function
)
def _update_normalizer_progress(self, step_key: str, success: bool, message: str) -> None:
"""
Thread-safe method to update the normalizer UI with progress.
This function is called by the background thread.
"""
# Ensure GUI updates happen in the main thread
self.master.after(0, self._populate_normalizer_step, step_key, success, message)
def _populate_normalizer_step(self, step_key: str, success: bool, message: str):
"""The actual GUI update logic."""
step_map = {
"venv_creation": "1. Create Virtual Environment", "analysis": "2. Analyze Dependencies",
"requirements_generation": "3. Generate requirements.txt", "dependency_installation": "4. Install Dependencies",
"pyproject_creation": "5. Create pyproject.toml",
}
# This logic handles both new steps and updates to existing steps (like installation)
step_name = step_map.get(step_key, step_key.replace("_", " ").title())
icon = "" if success else ""
# Find if the step is already in the list
list_items = self.normalizer_steps_list.get(0, tk.END)
try:
# Find the index of the main step (e.g., "4. Install Dependencies")
main_step_name_prefix = step_name.split(" ")[0]
index = next(i for i, item in enumerate(list_items) if item.strip().startswith(main_step_name_prefix))
# Update the main step's final status
self.normalizer_steps_list.delete(index)
self.normalizer_steps_list.insert(index, f" {icon} {step_name}")
self.normalizer_steps_list.itemconfig(index, {'fg': 'green' if success else 'red'})
self.normalizer_step_messages[index] = message
except StopIteration:
# If step is not found, it's a new main step
index = self.normalizer_steps_list.size()
self.normalizer_steps_list.insert(tk.END, f" {icon} {step_name}")
self.normalizer_steps_list.itemconfig(index, {'fg': 'green' if success else 'red'})
self.normalizer_step_messages.append(message)
# Handle sub-step messages (like individual package installations)
if "Installing '" in message and success:
self.normalizer_steps_list.insert(tk.END, f" - {message.split(' ')[1]}")
self.normalizer_steps_list.itemconfig(tk.END, {'fg': 'grey'})
self.normalizer_step_messages.append(message) # Add message for sub-step too
# Auto-select the last updated/added item
last_index = self.normalizer_steps_list.size() - 1
self.normalizer_steps_list.selection_clear(0, tk.END)
self.normalizer_steps_list.selection_set(last_index)
self.normalizer_steps_list.see(last_index) # Scroll to the new item
self._on_normalizer_step_select(None) # Show details
def _normalize_project_callback(self, results: Dict[str, Tuple[bool, str]]) -> None:
"""Handles the results of the normalization, populating the new UI."""
logging.info("Normalization process finished. Displaying results.")
self.normalizer_steps_list.delete(0, tk.END)
self.normalizer_step_messages.clear()
step_map = {
"venv_creation": "1. Create Virtual Environment", "analysis": "2. Analyze Dependencies",
"requirements_generation": "3. Generate requirements.txt", "dependency_installation": "4. Install Dependencies",
"pyproject_creation": "5. Create pyproject.toml",
}
for i, (step_key, (success, message)) in enumerate(results.items()):
step_name = step_map.get(step_key, step_key.replace("_", " ").title())
icon = "" if success else ""
self.normalizer_steps_list.insert(tk.END, f" {icon} {step_name}")
self.normalizer_steps_list.itemconfig(i, {'fg': 'green' if success else 'red'})
self.normalizer_step_messages.append(message)
# Automatically select the last step to show its details, especially useful for errors
if self.normalizer_steps_list.size() > 0:
last_index = self.normalizer_steps_list.size() - 1
self.normalizer_steps_list.selection_set(last_index)
self._on_normalizer_step_select(None) # Trigger the event handler manually
def _on_normalizer_step_select(self, event: Optional[tk.Event]) -> None:
"""Displays the details for the currently selected normalization step."""
# This function remains largely the same
selected_indices = self.normalizer_steps_list.curselection()
if not selected_indices: return
selected_index = selected_indices[0]
try:
message = self.normalizer_step_messages[selected_index]
self.normalizer_details_text.config(state=tk.NORMAL)
self.normalizer_details_text.delete('1.0', tk.END)
self.normalizer_details_text.insert('1.0', message)
self.normalizer_details_text.config(state=tk.DISABLED)
except IndexError:
pass
if __name__ == "__main__":
root = tk.Tk()
logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
app = DependencyAnalyzerApp(master=root)
root.minsize(width=800, height=700)
app.mainloop()