# 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('<>', 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()