SXXXXXXX_DependencyAnalyzer/dependencyanalyzer/gui.py
2025-11-10 14:18:35 +01:00

529 lines
25 KiB
Python

# dependencyanalyzer/gui.py
import logging
import re
import sys
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
# Updated import to include the new normalizer module
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):
"""Writes the log record to the Text widget in a thread-safe manner."""
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
self._create_widgets()
self._setup_logging_handler()
self._update_button_states()
def _create_widgets(self) -> None:
"""Creates and lays out the GUI widgets."""
# --- 1. Global Repository Selection (Moved outside the notebook) ---
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)
# --- 2. Main Tab Control (Notebook) ---
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")
# Populate the tabs
self._create_analyzer_tab_widgets(self.analyzer_tab)
self._create_normalizer_tab_widgets(self.normalizer_tab)
# --- 3. Log Area Widget (shared at the bottom) ---
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."""
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."""
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)
results_frame = ttk.LabelFrame(parent_frame, text="3. Normalization Results", padding=(10, 5))
results_frame.pack(fill=tk.BOTH, expand=True, pady=5)
self.normalizer_results_tree = ttk.Treeview(
results_frame,
columns=("step", "status", "details"),
show="headings",
)
self.normalizer_results_tree.heading("step", text="Step")
self.normalizer_results_tree.column("step", width=180, stretch=tk.NO, anchor=tk.W)
self.normalizer_results_tree.heading("status", text="Status")
self.normalizer_results_tree.column("status", width=100, stretch=tk.NO, anchor=tk.W)
self.normalizer_results_tree.heading("details", text="Details")
self.normalizer_results_tree.column("details", width=400, stretch=tk.YES, anchor=tk.W)
results_scrollbar = ttk.Scrollbar(
results_frame, orient="vertical", command=self.normalizer_results_tree.yview
)
self.normalizer_results_tree.configure(yscrollcommand=results_scrollbar.set)
results_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.normalizer_results_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
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:
"""Enables/disables buttons based on the current state."""
repo_selected = bool(self.selected_repository_path)
try:
# Analyzer tab
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)
# Normalizer tab
self.normalize_button.config(state=tk.NORMAL if repo_selected else tk.DISABLED)
except tk.TclError:
pass
def _select_repository(self) -> None:
"""Opens a dialog to select a folder, then checks its status."""
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}")
# Reset state for both tabs
self.std_lib_deps_info = {}
self.external_deps_info = {}
self.extracted_dependencies_names = set()
self.requirements_file_path = None
self.modules_tree.delete(*self.modules_tree.get_children())
self.comparison_tree.delete(*self.comparison_tree.get_children())
self.normalizer_results_tree.delete(*self.normalizer_results_tree.get_children())
self._update_button_states()
status = project_normalizer.check_project_status(self.selected_repository_path)
if not all(status.values()):
message = (
"This project appears to be missing a standard setup "
"(e.g., virtual environment, pyproject.toml).\n\n"
"Would you like to switch to the 'Project Normalizer' tab to create them?"
)
if messagebox.askyesno("Project Setup Incomplete", message):
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:
error_msg = f"Error in '{task_function.__name__}': {e}"
logging.error(error_msg)
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_to_toggle = [
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_to_toggle:
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 ---
def _analyze_and_generate_reqs_threaded(self) -> None:
if not self.selected_repository_path:
messagebox.showwarning("Warning", "Please select a project repository first.")
return
self._run_long_task_threaded(
self._perform_analysis_and_generation,
callback_success=self._analysis_and_generation_callback,
)
def _perform_analysis_and_generation(
self,
) -> Tuple[Path, analyzer.DependencyInfo, analyzer.DependencyInfo]:
"""Performs analysis and generates the requirements file. Runs in a thread."""
if not self.selected_repository_path:
raise ValueError("Repository path not set when analysis task started.")
repo_path = self.selected_repository_path
scan_path: Path
potential_sub_pkg = repo_path / repo_path.name.lower()
if potential_sub_pkg.is_dir():
logging.info(f"Found sub-directory '{potential_sub_pkg.name}', scanning within it.")
scan_path = potential_sub_pkg
else:
logging.info(f"Scanning the selected project root '{repo_path}'.")
scan_path = repo_path
std_lib_info, external_info = analyzer.find_project_modules_and_dependencies(
repo_path=repo_path, scan_path=scan_path
)
req_file_path = package_manager.generate_requirements_file(
repo_path, external_info, std_lib_info
)
return req_file_path, std_lib_info, external_info
def _analysis_and_generation_callback(
self, result: Tuple[Path, analyzer.DependencyInfo, analyzer.DependencyInfo]
) -> None:
"""Callback after analysis and requirements.txt generation."""
req_file_path, std_lib_info, external_info = result
self.requirements_file_path = req_file_path
self.std_lib_deps_info = std_lib_info
self.external_deps_info = external_info
self.extracted_dependencies_names = set(self.external_deps_info.keys())
logging.info(f"Analysis complete. Requirements file created: {self.requirements_file_path}")
self._populate_modules_tree()
self._update_button_states()
def _populate_modules_tree(self) -> None:
"""Populates the modules treeview with external dependency names."""
self.modules_tree.delete(*self.modules_tree.get_children())
if self.extracted_dependencies_names:
for dep_name in sorted(list(self.extracted_dependencies_names)):
self.modules_tree.insert("", tk.END, values=(dep_name,))
elif self.requirements_file_path and self.requirements_file_path.exists():
logging.debug("Populating tree from requirements.txt as a fallback.")
try:
with open(self.requirements_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
match = re.match(r"([a-zA-Z0-9._-]+)", line)
if match:
self.modules_tree.insert("", tk.END, values=(match.group(1),))
except Exception as e:
logging.warning(f"Error reading requirements file for tree view: {e}")
def _download_packages_threaded(self) -> None:
if not self.requirements_file_path:
messagebox.showwarning("Warning", "Please generate a requirements.txt file first.")
return
self._run_long_task_threaded(
package_manager.download_packages,
self.selected_repository_path, self.requirements_file_path,
callback_success=self._download_packages_callback,
)
def _download_packages_callback(self, result: Tuple[bool, str]) -> None:
success, message = result
log_level = logging.INFO if success else logging.ERROR
logging.log(log_level, f"Download Task: {message}")
if not success:
messagebox.showerror("Download Failed", message)
def _create_install_scripts_threaded(self) -> None:
if not self.requirements_file_path:
messagebox.showwarning("Warning", "Please generate a requirements.txt file first.")
return
self._run_long_task_threaded(
package_manager.create_install_scripts,
self.selected_repository_path, self.requirements_file_path,
callback_success=self._create_install_scripts_callback,
)
def _create_install_scripts_callback(self, result: Tuple[bool, str]) -> None:
success, message = result
log_level = logging.INFO if success else logging.ERROR
logging.log(log_level, f"Script Creation Task: {message}")
def _compare_packages_threaded(self) -> None:
if not self.requirements_file_path:
messagebox.showwarning("Warning", "Please generate a requirements.txt file first.")
return
self._run_long_task_threaded(
self._get_comparison_data,
callback_success=self._compare_packages_callback,
)
def _get_comparison_data(self) -> List[Dict[str, str]]:
"""Helper to run comparison logic in a thread."""
if not self.requirements_file_path:
raise ValueError("Requirements file path is not set.")
installed_packages = package_manager.get_installed_packages()
return package_manager.compare_requirements_with_installed(
self.requirements_file_path, installed_packages
)
def _compare_packages_callback(self, results: List[Dict[str, str]]) -> None:
"""Updates the comparison treeview with results."""
self.comparison_tree.delete(*self.comparison_tree.get_children())
if results:
for item in results:
self.comparison_tree.insert(
"", tk.END,
values=(
item["package"], item["required"],
item["installed"], item["status"],
),
tags=(item["status"],),
)
logging.info("Package comparison results displayed.")
else:
logging.info("No comparison results to display.")
self._update_button_states()
def _update_system_packages_threaded(self) -> None:
selected_items = self.comparison_tree.selection()
if not selected_items:
messagebox.showwarning("No Selection", "No packages selected in the table for update.")
return
packages_to_update = [
self.comparison_tree.item(item, "values")[0]
for item in selected_items
if self.comparison_tree.item(item, "values")[-1] != "OK"
]
if not packages_to_update:
messagebox.showinfo("No Action Needed", "Selected packages do not require an update.")
return
if not self.selected_repository_path:
logging.error("Repository path context lost. Cannot update packages.")
return
if messagebox.askyesno(
"Confirm System Package Update",
f"This will install/upgrade in your current Python environment:\n\n"
f" - {', '.join(packages_to_update)}\n\nProceed?"
):
self._run_long_task_threaded(
package_manager.update_system_packages,
packages_to_update, self.selected_repository_path,
callback_success=self._update_system_packages_callback,
)
else:
logging.info("System package update cancelled by user.")
def _update_system_packages_callback(self, result: Tuple[bool, str]) -> None:
success, message = result
log_level = logging.INFO if success else logging.ERROR
logging.log(log_level, f"Update Task: {message}")
if success:
logging.info("Re-running comparison after update attempt...")
self._compare_packages_threaded()
# --- 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_results_tree.delete(*self.normalizer_results_tree.get_children())
self._run_long_task_threaded(
project_normalizer.normalize_project,
self.selected_repository_path,
callback_success=self._normalize_project_callback,
)
def _normalize_project_callback(
self, results: Dict[str, Tuple[bool, str]]
) -> None:
"""Handles the results of the normalization process and updates the GUI."""
logging.info("Normalization process finished. Displaying results.")
self.normalizer_results_tree.delete(*self.normalizer_results_tree.get_children())
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",
}
# Use colors for status tags
self.normalizer_results_tree.tag_configure("Success", foreground="green")
self.normalizer_results_tree.tag_configure("Failed", foreground="red")
for step_key, (success, message) in results.items():
step_name = step_map.get(step_key, step_key.replace("_", " ").title())
status = "Success" if success else "Failed"
self.normalizer_results_tree.insert(
"",
tk.END,
values=(step_name, status, message),
tags=(status,),
)
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()