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