diff --git a/dependencyanalyzer/__main__.py b/dependencyanalyzer/__main__.py index 40679f0..258a059 100644 --- a/dependencyanalyzer/__main__.py +++ b/dependencyanalyzer/__main__.py @@ -1,25 +1,26 @@ # dependencyanalyzer/__main__.py -import tkinter as tk -import logging # Import standard logging -import sys # For checking command line args, etc. (optional) -from pathlib import Path +import logging import os +import sys +import tkinter as tk +from pathlib import Path # Use absolute import assuming 'dependencyanalyzer' is runnable with `python -m` -# Or use relative if structure guarantees it. Let's assume absolute is safer if structure might change. -# If running with `python -m dependencyanalyzer` from parent dir, this works. try: - from dependencyanalyzer.gui import DependencyAnalyzerApp - from dependencyanalyzer import ( - core, - ) # To access core.logger if needed, though handled by root + # --- MODIFIED IMPORT --- + # We now import the GUI module and the new core package. + from dependencyanalyzer import gui + from dependencyanalyzer import core # This still works to access the package + # --- END MODIFIED IMPORT --- except ImportError: - # Fallback for running __main__.py directly inside the package dir during dev + # Fallback for running __main__.py directly during development print( "Running __main__.py directly, attempting relative imports...", file=sys.stderr ) + # --- MODIFIED IMPORT (FALLBACK) --- from gui import DependencyAnalyzerApp import core + # --- END MODIFIED IMPORT (FALLBACK) --- def setup_logging(): @@ -29,15 +30,12 @@ def setup_logging(): log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - # Use force=True on Python 3.8+ to ensure handlers are replaced if already configured + # Use force=True on Python 3.8+ to ensure handlers are replaced kwargs = {"force": True} if sys.version_info >= (3, 8) else {} - # Basic config - consider adding FileHandler later if needed logging.basicConfig(level=log_level, format=log_format, stream=sys.stdout, **kwargs) # type: ignore logging.info(f"Root logger configured. Level: {logging.getLevelName(log_level)}") - # Ensure core logger level is at least as verbose as root, if needed - # logging.getLogger(core.__name__).setLevel(log_level) def main(): @@ -45,12 +43,13 @@ def main(): setup_logging() # Configure logging first root = tk.Tk() - root.minsize(width=800, height=700) # Set a reasonable minimum size - app = DependencyAnalyzerApp(master=root) + root.minsize(width=800, height=700) + # --- MODIFIED CLASS NAME --- + # The class is now accessed via the imported gui module + app = gui.DependencyAnalyzerApp(master=root) + # --- END MODIFIED CLASS NAME --- app.mainloop() if __name__ == "__main__": - # This block runs when script is executed directly (python __main__.py) - # or via python -m dependencyanalyzer - main() + main() \ No newline at end of file diff --git a/dependencyanalyzer/core.py b/dependencyanalyzer/core.py deleted file mode 100644 index 212d186..0000000 --- a/dependencyanalyzer/core.py +++ /dev/null @@ -1,1098 +0,0 @@ -# dependencyanalyzer/core.py -import ast -import os -import shutil # For copying files -import subprocess -import sys -import json -import logging -import importlib.util -from pathlib import Path -import sysconfig -import re -from typing import List, Dict, Set, Tuple, Optional, Union, Any, Callable - -# --- Logger Configuration --- -logger = logging.getLogger(__name__) # Use __name__ for logger hierarchy - -# --- Standard Library Detection Logic --- -if sys.version_info >= (3, 10): - try: - STANDARD_LIBRARY_MODULES: frozenset[str] = sys.stdlib_module_names - logger.debug( - "Using sys.stdlib_module_names for standard library module list (Python 3.10+)." - ) - except AttributeError: - logger.warning( - "sys.stdlib_module_names not found despite Python 3.10+. Using predefined list." - ) - STANDARD_LIBRARY_MODULES = frozenset() # Will be populated below -else: - logger.debug( - "Using a predefined list for standard library modules (Python < 3.10)." - ) - # Predefined list (assign directly here) - _PREDEFINED_STDLIBS = { - "abc", - "aifc", - "argparse", - "array", - "ast", - "asynchat", - "asyncio", - "asyncore", - "atexit", - "audioop", - "base64", - "bdb", - "binascii", - "binhex", - "bisect", - "builtins", - "bz2", - "calendar", - "cgi", - "cgitb", - "chunk", - "cmath", - "cmd", - "code", - "codecs", - "codeop", - "collections", - "colorsys", - "compileall", - "concurrent", - "configparser", - "contextlib", - "contextvars", - "copy", - "copyreg", - "cProfile", - "crypt", - "csv", - "ctypes", - "curses", - "dataclasses", - "datetime", - "dbm", - "decimal", - "difflib", - "dis", - "distutils", - "doctest", - "email", - "encodings", - "ensurepip", - "enum", - "errno", - "faulthandler", - "fcntl", - "filecmp", - "fileinput", - "fnmatch", - "formatter", - "fractions", - "ftplib", - "functools", - "gc", - "getopt", - "getpass", - "gettext", - "glob", - "graphlib", - "grp", - "gzip", - "hashlib", - "heapq", - "hmac", - "html", - "http", - "idlelib", - "imaplib", - "imghdr", - "imp", - "importlib", - "inspect", - "io", - "ipaddress", - "itertools", - "json", - "keyword", - "lib2to3", - "linecache", - "locale", - "logging", - "lzma", - "mailbox", - "mailcap", - "marshal", - "math", - "mimetypes", - "mmap", - "modulefinder", - "multiprocessing", - "netrc", - "nis", - "nntplib", - "numbers", - "operator", - "optparse", - "os", - "ossaudiodev", - "parser", - "pathlib", - "pdb", - "pickle", - "pickletools", - "pipes", - "pkgutil", - "platform", - "plistlib", - "poplib", - "posix", - "pprint", - "profile", - "pstats", - "pty", - "pwd", - "py_compile", - "pyclbr", - "pydoc", - "pydoc_data", - "pyexpat", - "queue", - "quopri", - "random", - "re", - "readline", - "reprlib", - "resource", - "rlcompleter", - "runpy", - "sched", - "secrets", - "select", - "selectors", - "shelve", - "shlex", - "shutil", - "signal", - "site", - "smtpd", - "smtplib", - "sndhdr", - "socket", - "socketserver", - "spwd", - "sqlite3", - "ssl", - "stat", - "statistics", - "string", - "stringprep", - "struct", - "subprocess", - "sunau", - "symtable", - "sys", - "sysconfig", - "syslog", - "tabnanny", - "tarfile", - "telnetlib", - "tempfile", - "termios", - "textwrap", - "threading", - "time", - "timeit", - "tkinter", - "token", - "tokenize", - "trace", - "traceback", - "tracemalloc", - "tty", - "turtle", - "turtledemo", - "types", - "typing", - "unicodedata", - "unittest", - "urllib", - "uu", - "uuid", - "venv", - "warnings", - "wave", - "weakref", - "webbrowser", - "wsgiref", - "xdrlib", - "xml", - "xmlrpc", - "zipapp", - "zipfile", - "zipimport", - "zlib", - "_thread", - "_collections_abc", - "_json", - "_datetime", - "_weakrefset", - "_strptime", - "_socket", - "_ssl", - "_struct", - "_queue", - "_pickle", - "_lsprof", - "_heapq", - "_hashlib", - "_csv", - "_bz2", - "_codecs", - "_bisect", - "_blake2", - "_asyncio", - "_ast", - "_abc", - } - STANDARD_LIBRARY_MODULES = frozenset(_PREDEFINED_STDLIBS) - -# Final check/assignment if Python 3.10+ but stdlib_module_names failed -if sys.version_info >= (3, 10) and ( - not isinstance(STANDARD_LIBRARY_MODULES, frozenset) or not STANDARD_LIBRARY_MODULES -): - logger.error( - "Could not determine standard library modules for Python 3.10+. Stdlib check may be unreliable." - ) - # Assign empty or predefined as last resort? Let's stick with empty to signal failure. - STANDARD_LIBRARY_MODULES = frozenset() - -_CACHED_STD_LIB_PATHS: Optional[Set[str]] = None - - -def get_standard_library_paths() -> Set[str]: - """Retrieves and caches normalized paths for the Python standard library.""" - global _CACHED_STD_LIB_PATHS - if _CACHED_STD_LIB_PATHS is not None: - return _CACHED_STD_LIB_PATHS - paths = set() - logger.debug("Determining standard library paths...") - try: - for path_name in ("stdlib", "platstdlib"): - try: - path_val = sysconfig.get_path(path_name) - if path_val and os.path.isdir(path_val): - paths.add(os.path.normpath(path_val)) - logger.debug(f"Found stdlib path ({path_name}): {path_val}") - except Exception as e: - logger.warning(f"Could not get sysconfig path '{path_name}': {e}") - prefix_lib_path = os.path.normpath( - os.path.join( - sys.prefix, - "lib", - f"python{sys.version_info.major}.{sys.version_info.minor}", - ) - ) - if os.path.isdir(prefix_lib_path): - paths.add(prefix_lib_path) - logger.debug(f"Found stdlib path (prefix): {prefix_lib_path}") - if sys.platform == "win32": - dlls_path = os.path.join(sys.prefix, "DLLs") - if os.path.isdir(dlls_path): - paths.add(os.path.normpath(dlls_path)) - logger.debug(f"Found stdlib path (DLLs): {dlls_path}") - else: - dynload_path = os.path.join( - sys.exec_prefix, - "lib", - f"python{sys.version_info.major}.{sys.version_info.minor}", - "lib-dynload", - ) - if os.path.isdir(dynload_path): - paths.add(os.path.normpath(dynload_path)) - logger.debug(f"Found stdlib path (dynload): {dynload_path}") - fw_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") - if fw_prefix and isinstance(fw_prefix, str) and os.path.isdir(fw_prefix): - fw_path = os.path.normpath( - os.path.join( - fw_prefix, - "lib", - f"python{sys.version_info.major}.{sys.version_info.minor}", - ) - ) - if os.path.isdir(fw_path): - paths.add(fw_path) - logger.debug(f"Found stdlib path (Framework): {fw_path}") - if not paths: - logger.warning( - "Sysconfig paths failed, attempting fallback using 'os' and 'sysconfig' module locations." - ) - try: - paths.add(os.path.normpath(os.path.dirname(os.__file__))) - except Exception: - logger.error("Could not determine path for 'os' module.") - try: - paths.add(os.path.normpath(os.path.dirname(sysconfig.__file__))) - except Exception: - logger.error("Could not determine path for 'sysconfig' module.") - except Exception as e: - logger.exception(f"Unexpected error determining standard library paths: {e}") - _CACHED_STD_LIB_PATHS = {p for p in paths if p} - if not _CACHED_STD_LIB_PATHS: - logger.error("Failed to determine ANY standard library paths.") - else: - logger.debug(f"Final cached standard library paths: {_CACHED_STD_LIB_PATHS}") - return _CACHED_STD_LIB_PATHS - - -def is_path_in_standard_library(file_path_str: Optional[str]) -> bool: - """Checks if a file path string is within any known standard library directory.""" - if not file_path_str: - return False - std_lib_paths = get_standard_library_paths() - if not std_lib_paths: - return False - try: - norm_file_path = os.path.normpath(os.path.abspath(file_path_str)) - path_parts = Path(norm_file_path).parts - if "site-packages" in path_parts or "dist-packages" in path_parts: - return False - for std_path in std_lib_paths: - norm_std_path = os.path.normpath(os.path.abspath(std_path)) - if norm_file_path.startswith(norm_std_path + os.sep): - return True - except Exception as e: - logger.warning(f"Error during path comparison for '{file_path_str}': {e}") - return False - return False - - -def _is_standard_library(module_name: str) -> bool: - """Checks if a module name belongs to the standard library.""" - if module_name in STANDARD_LIBRARY_MODULES: - logger.debug(f"'{module_name}' in predefined list.") - return True - try: - spec = importlib.util.find_spec(module_name) - except: - logger.debug(f"Spec finding failed for '{module_name}'. Assuming non-standard.") - return False - if spec is None: - logger.debug(f"No spec for '{module_name}'. Assuming non-standard.") - return False - origin = spec.origin - logger.debug(f"'{module_name}': origin='{origin}'") - if origin == "built-in" or origin == "frozen": - logger.debug(f"'{module_name}' is '{origin}'.") - return True - if is_path_in_standard_library(origin): - logger.debug(f"'{module_name}' origin '{origin}' IS stdlib path.") - return True - logger.debug(f"'{module_name}' origin '{origin}' NOT stdlib path.") - return False - - -MODULE_NAME_TO_PACKAGE_NAME_MAP: Dict[str, str] = { - "PIL": "Pillow", - "cv2": "opencv-python", -} -FALSE_POSITIVE_EXTERNAL_MODULES: Set[str] = {"mpl_toolkits"} - - -class ImportExtractor(ast.NodeVisitor): - """AST visitor extracts (module_name, relative_file_path_string) tuples.""" - - def __init__(self, file_path_str: str): - super().__init__() - self.file_path_str = file_path_str - self.imported_modules: Set[Tuple[str, str]] = set() - - def visit_Import(self, node: ast.Import): - for alias in node.names: - module_name = alias.name.split(".")[0] - if module_name: - self.imported_modules.add((module_name, self.file_path_str)) - self.generic_visit(node) - - def visit_ImportFrom(self, node: ast.ImportFrom): - if node.module and node.level == 0: - module_name = node.module.split(".")[0] - if module_name: - self.imported_modules.add((module_name, self.file_path_str)) - - -DependencyInfo = Dict[str, Dict[str, Union[Set[str], Optional[str], str]]] - - -def find_project_modules_and_dependencies( - repo_path: Path, # Original user-selected path - scan_path: Path, # Path where the actual scanning begins -) -> Tuple[DependencyInfo, DependencyInfo]: - """ - Analyzes Python files starting from scan_path, identifies project modules - relative to scan_path, and finds standard/external dependencies. - Explicitly ignores imports matching the name of the scan_path directory if - scan_path is different from repo_path (assuming it's the main package). - - Args: - repo_path (Path): The root path selected by the user. - scan_path (Path): The directory to actually scan for source code. - - Returns: - Tuple[DependencyInfo, DependencyInfo]: std_lib_info, external_deps_info - """ - all_imports_locations: Dict[str, Set[str]] = {} - # project_modules: Set[str] = set() # Identifying project modules is complex, let's rely on scan_path name - - # --- NUOVA LOGICA: Identifica il nome del pacchetto principale (se si scansiona sottocartella) --- - main_project_package_name: Optional[str] = None - if repo_path != scan_path and scan_path.name == repo_path.name.lower(): - main_project_package_name = scan_path.name - logger.info( - f"Assuming '{main_project_package_name}' is the main project package being scanned." - ) - # --- FINE NUOVA LOGICA --- - - # Identify potential project modules *within* scan_path can still be useful - # but let's simplify the primary check based on main_project_package_name first. - # logger.info(f"Analysis target: Identifying project modules within '{scan_path}'...") - # ... (previous logic for project_modules identification removed for simplification, - # could be added back if needed for more complex internal structures) - - logger.info(f"Analyzing Python files for imports starting from '{scan_path}'...") - excluded_dirs = { - "venv", - ".venv", - "env", - ".env", - "docs", - "tests", - "test", - "site-packages", - "dist-packages", - "__pycache__", - ".git", - ".hg", - ".svn", - ".tox", - ".nox", - "build", - "dist", - "*.egg-info", - } - file_count = 0 - for root, dirs, files in os.walk(scan_path, topdown=True): - dirs[:] = [d for d in dirs if d not in excluded_dirs and not d.startswith(".")] - current_root_path = Path(root) - for file_name in files: - if file_name.endswith(".py"): - file_path_obj = current_root_path / file_name - file_count += 1 - try: - report_rel_path_str = str(file_path_obj.relative_to(repo_path)) - except ValueError: - report_rel_path_str = str(file_path_obj) - logger.warning(f"Path not relative: {file_path_obj}") - logger.debug(f"Parsing: {report_rel_path_str}") - try: - with open( - file_path_obj, "r", encoding="utf-8", errors="ignore" - ) as f: - source = f.read() - tree = ast.parse(source, filename=str(file_path_obj)) - extractor = ImportExtractor(file_path_str=report_rel_path_str) - extractor.visit(tree) - for module, report_rel_path in extractor.imported_modules: - if module: - all_imports_locations.setdefault(module, set()).add( - report_rel_path - ) - except SyntaxError as e: - logger.warning( - f"Syntax error in '{report_rel_path_str}': {e}. Skipping." - ) - except Exception as e: - logger.exception( - f"Error processing file '{report_rel_path_str}': {e}" - ) - - logger.info( - f"Analyzed {file_count} Python files. Found {len(all_imports_locations)} unique top-level imports." - ) - logger.info("Classifying imports and fetching versions...") - std_libs: DependencyInfo = {} - external_deps: DependencyInfo = {} - - for imp_module, locs in all_imports_locations.items(): - # --- MODIFIED CHECK: Ignore if it matches the main project package name --- - if main_project_package_name and imp_module == main_project_package_name: - logger.info( - f"Skipping '{imp_module}' as it matches the main project package name being scanned." - ) - continue - # --- FINE MODIFIED CHECK --- - - # Remove the check against the complex 'project_modules' set for now - # if imp_module in project_modules: logger.debug(f"Skipping project module: '{imp_module}'"); continue - - if imp_module in FALSE_POSITIVE_EXTERNAL_MODULES: - logger.info(f"Skipping known false positive: '{imp_module}'") - continue - - if _is_standard_library(imp_module): - logger.debug(f"'{imp_module}' is standard library.") - std_libs[imp_module] = {"locations": locs, "version": None} - else: - # External dependency processing (mapping, version check) - pypi_name = MODULE_NAME_TO_PACKAGE_NAME_MAP.get(imp_module, imp_module) - orig_imp = imp_module if pypi_name != imp_module else None - logger.debug( - f"'{imp_module}' (PyPI: '{pypi_name}') is external. Fetching version..." - ) - version: Optional[str] = None - try: - version = importlib.metadata.version(pypi_name) - except: - logger.warning(f"Version for '{pypi_name}' not found.") - - dep_data = external_deps.setdefault( - pypi_name, - {"locations": set(), "version": version, "original_import_name": None}, - ) - dep_data["locations"].update(locs) - # type: ignore - if orig_imp and dep_data.get("original_import_name") is None: - dep_data["original_import_name"] = orig_imp - if dep_data.get("version") is None and version is not None: - dep_data["version"] = version - - logger.info( - f"Classification complete: {len(std_libs)} stdlib used, {len(external_deps)} unique external dependencies." - ) - return std_libs, external_deps - - -# --- find_main_script DEFINITION --- -def find_main_script(repo_path: Path) -> Optional[Path]: - """Finds the main script (__main__.py) in standard locations.""" - repo_name = repo_path.name - script_subfolder_name = repo_name.lower() - path_subdir = repo_path / script_subfolder_name / "__main__.py" - path_root = repo_path / "__main__.py" - logger.info(f"Looking for main script at: '{path_subdir}' or '{path_root}'") - if path_subdir.is_file(): - logger.info(f"Main script found: {path_subdir}") - return path_subdir - elif path_root.is_file(): - logger.info(f"Main script found: {path_root}") - return path_root - else: - logger.warning(f"Main script (__main__.py) not found in standard locations.") - return None - - -def generate_requirements_file( - repo_path: Path, - external_deps_info: DependencyInfo, - std_lib_deps_info: DependencyInfo, -) -> Path: - """Generates requirements.txt with detailed comments.""" - req_file_path = repo_path / "requirements.txt" - py_ver = ( - f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" - ) - logger.info(f"Generating '{req_file_path}'...") - try: - with open(req_file_path, "w", encoding="utf-8") as f: - f.write( - f"# Requirements generated by DependencyAnalyzer for {repo_path.name}\n" - ) - f.write(f"# Python Version (analysis env): {py_ver}\n\n") - f.write( - f"# --- Standard Library Modules Used (part of Python {py_ver}) ---\n" - ) - if not std_lib_deps_info: - f.write("# (No stdlib imports detected)\n") - else: - for name in sorted(std_lib_deps_info.keys()): - locs = std_lib_deps_info[name].get("locations", set()) - loc_list = sorted(list(locs)) if isinstance(locs, set) else [] - loc_str = ( - f"Used in: {', '.join(loc_list[:3])}{', ...' if len(loc_list) > 3 else ''}" - if loc_list - else "" - ) - f.write(f"# {name} ({loc_str})\n".replace(" ()", "")) - f.write("\n# --- External Dependencies (for pip install) ---\n") - if not external_deps_info: - f.write("# (No external dependencies detected)\n") - else: - for pypi_name in sorted(external_deps_info.keys()): - info = external_deps_info[pypi_name] - locs = info.get("locations", set()) - ver = info.get("version") - orig_imp = info.get("original_import_name") - loc_list = sorted(list(locs)) if isinstance(locs, set) else [] - imp_as = f"(imported as '{orig_imp}') " if orig_imp else "" - loc_str = ( - f"# Found {imp_as}in: {', '.join(loc_list[:3])}{', ...' if len(loc_list) > 3 else ''}" - if loc_list - else "# Location info unavailable" - ) - ver_str = ( - f"# Detected version: {ver}" - if ver - else "# Version not detected in analysis env" - ) - f.write(f"{loc_str}\n{ver_str}\n{pypi_name}\n\n") - logger.info(f"Successfully generated '{req_file_path}'.") - except IOError as e: - logger.exception(f"Error writing requirements.txt: {e}") - raise - return req_file_path - - -def _copy_requirements_to_packages_dir(repo_path: Path, packages_dir: Path) -> bool: - """Copies requirements.txt from repo root to packages directory.""" - source_req = repo_path / "requirements.txt" - dest_req = packages_dir / "requirements.txt" - if not source_req.is_file(): - logger.warning(f"Source '{source_req}' not found, cannot copy.") - return False - try: - shutil.copy2(source_req, dest_req) - logger.info(f"Copied '{source_req.name}' to '{packages_dir}'.") - return True - except Exception as e: - logger.exception(f"Failed to copy '{source_req.name}': {e}") - return False - - -def download_packages( - repo_path: Path, requirements_file_path_in_repo: Path -) -> Tuple[bool, str]: - """Downloads packages one-by-one based on requirements file.""" - packages_dir = repo_path / "_req_packages" - packages_dir.mkdir(parents=True, exist_ok=True) - logger.info(f"Starting package download to '{packages_dir}'...") - if not requirements_file_path_in_repo.is_file(): - msg = f"Source requirements file '{requirements_file_path_in_repo}' not found." - logger.error(msg) - return False, msg - _copy_requirements_to_packages_dir(repo_path, packages_dir) - - packages_to_download: List[str] = [] - try: - with open(requirements_file_path_in_repo, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and not line.startswith("-"): - packages_to_download.append(line) - except Exception as e: - msg = f"Error reading requirements file '{requirements_file_path_in_repo}': {e}" - logger.exception(msg) - return False, msg - if not packages_to_download: - msg = f"No package specifications found in '{requirements_file_path_in_repo}'." - logger.info(msg) - return True, msg - - logger.info( - f"Found {len(packages_to_download)} specifications. Downloading individually..." - ) - overall_success = True - failed = [] - succeeded = [] - for pkg_spec in packages_to_download: - logger.info(f"--- Downloading: {pkg_spec} ---") - cmd = [ - sys.executable, - "-m", - "pip", - "download", - pkg_spec, - "-d", - str(packages_dir.resolve()), - "--no-cache-dir", - "--disable-pip-version-check", - ] - logger.debug(f"Running: {' '.join(cmd)}") - proc = None - try: - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - creationflags=( - subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0 - ), - ) - stdout, stderr = proc.communicate(timeout=300) - if proc.returncode == 0: - logger.info(f"OK: '{pkg_spec}' downloaded (or already present).") - succeeded.append(pkg_spec) - if stderr and stderr.strip(): - logger.warning(f"Pip stderr for '{pkg_spec}':\n{stderr.strip()}") - if stdout and stdout.strip(): - logger.debug( - f"Pip stdout for '{pkg_spec}':\n{stdout.strip()[:300]}..." - ) - else: - raise Exception( - f"Pip error (code {proc.returncode}):\nStderr: {stderr.strip()}\nStdout: {stdout.strip()}" - ) # Include stdout in error - except Exception as e: - error_msg = f"Failed to download '{pkg_spec}': {str(e)}" # Use str(e) for cleaner log message - logger.error(error_msg) # Log specific error - # Log traceback for unexpected errors, not for timeouts or known subprocess errors unless needed - if not isinstance( - e, (subprocess.TimeoutExpired, subprocess.CalledProcessError) - ) and not "Pip error" in str(e): - logger.exception("Traceback for unexpected download error:") - if isinstance(e, subprocess.TimeoutExpired) and proc: - proc.kill() - proc.wait() - overall_success = False - failed.append(pkg_spec) - # logger.info(f"--- Finished: {pkg_spec} ---") # Can be a bit verbose, log summary instead - - summary = f"Download finished. Attempted: {len(packages_to_download)}, Succeeded: {len(succeeded)}, Failed: {len(failed)}." - if failed: - summary += f" Failures: {', '.join(failed)}" - logger.log(logging.ERROR if not overall_success else logging.INFO, summary) - return overall_success, summary - - -def create_install_scripts( - repo_path: Path, requirements_file_path_in_repo: Path -) -> Tuple[bool, str]: - """Creates install scripts with pip check and relative paths.""" - packages_dir = repo_path / "_req_packages" - packages_dir.mkdir(parents=True, exist_ok=True) - if not requirements_file_path_in_repo.is_file(): - return ( - False, - f"Source requirements file '{requirements_file_path_in_repo}' not found.", - ) - if not _copy_requirements_to_packages_dir(repo_path, packages_dir): - return False, "Failed to copy requirements.txt." - - req_file = "requirements.txt" # Relative name - bat = f"""@echo off & cls -echo ------------------------------------ -echo Package Installer for {repo_path.name} -echo ------------------------------------ -echo. -echo Checking for pip... -python -m pip --version >nul 2>&1 & if errorlevel 1 ( python3 -m pip --version >nul 2>&1 & if errorlevel 1 ( goto :pip_error ) ) -echo pip found. & goto :install -:pip_error -echo ERROR: Python pip module not found. -echo Please install pip for your Python environment. See: -echo https://pip.pypa.io/en/stable/installation/ -echo. & pause & exit /b 1 -:install -echo Installing packages from local folder using '{req_file}'... -echo Source: %~dp0 -echo. -python -m pip install --no-index --find-links "%~dp0" -r "%~dp0{req_file}" --disable-pip-version-check || ( python3 -m pip install --no-index --find-links "%~dp0" -r "%~dp0{req_file}" --disable-pip-version-check || ( goto :install_error ) ) -echo. & echo -------------------------- -echo Installation Successful! -echo -------------------------- -echo. & pause & exit /b 0 -:install_error -echo. & echo -------------------------- -echo ERROR: Installation Failed. -echo -------------------------- -echo Please check the messages above for details. & pause & exit /b 1 -""" - sh = f"""#!/bin/bash -echo "------------------------------------" -echo "Package Installer for {repo_path.name}" -echo "------------------------------------" -echo "" -echo "Checking for pip..." -PYTHON_CMD="" -if command -v python3 &>/dev/null && python3 -m pip --version &>/dev/null; then PYTHON_CMD="python3" -elif command -v python &>/dev/null && python -m pip --version &>/dev/null; then PYTHON_CMD="python" -else echo "ERROR: Python pip module not found for python3 or python."; echo "Install pip: https://pip.pypa.io/en/stable/installation/"; exit 1; fi -echo "pip found ($PYTHON_CMD)." - -SCRIPT_DIR="$( cd "$( dirname "${{BASH_SOURCE[0]}}" )" &> /dev/null && pwd )" -REQ_FILE="$SCRIPT_DIR/{req_file}" -echo "Installing packages from '$SCRIPT_DIR' using '$REQ_FILE'..." -echo "" -"$PYTHON_CMD" -m pip install --no-index --find-links "$SCRIPT_DIR" -r "$REQ_FILE" --disable-pip-version-check -INSTALL_STATUS=$? -echo "" -if [ $INSTALL_STATUS -ne 0 ]; then - echo "--------------------------"; echo "ERROR: Installation Failed."; echo "--------------------------"; echo "Check messages above."; exit 1; -else - echo "--------------------------"; echo "Installation Successful!"; echo "--------------------------"; exit 0; -fi -""" - try: - bat_path = packages_dir / "install_packages.bat" - sh_path = packages_dir / "install_packages.sh" - with open(bat_path, "w", encoding="utf-8", newline="\r\n") as f: - f.write(bat) - logger.info(f"Created {bat_path}") - with open(sh_path, "w", encoding="utf-8", newline="\n") as f: - f.write(sh) - os.chmod(sh_path, 0o755) - logger.info(f"Created {sh_path} (executable)") - return True, f"Installation scripts created in '{packages_dir}'." - except IOError as e: - logger.exception(f"Error creating install scripts: {e}") - return False, f"Error creating scripts: {e}" - - -def get_installed_packages() -> Dict[str, str]: - """Gets installed packages, normalizing names to lowercase and hyphens.""" - installed = {} - cmd = [ - sys.executable, - "-m", - "pip", - "list", - "--format=json", - "--disable-pip-version-check", - ] - logger.debug(f"Getting installed packages using: {' '.join(cmd)}") - try: - proc = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True, - encoding="utf-8", - errors="ignore", - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, - ) - pkgs = json.loads(proc.stdout) - for pkg in pkgs: - installed[pkg["name"].lower().replace("_", "-")] = pkg["version"] - logger.info(f"Found {len(installed)} installed packages.") - except FileNotFoundError: - logger.error(f"Command not found: {cmd[0]}. Is Python/pip in PATH?") - except subprocess.CalledProcessError as e: - logger.error(f"pip list command failed (code {e.returncode}): {e.stderr}") - except json.JSONDecodeError as e: - logger.error(f"Failed to parse pip list JSON output: {e}") - except Exception as e: - logger.exception(f"Unexpected error getting installed packages: {e}") - return installed - - -try: - from packaging.requirements import Requirement - from packaging.version import parse as parse_version, InvalidVersion - - PACKAGING_AVAILABLE = True - logger.debug("Using 'packaging' library.") -except ImportError: - PACKAGING_AVAILABLE = False - Requirement = None - parse_version = None - InvalidVersion = None - logger.warning("'packaging' not found, version checks limited.") - - -def compare_requirements_with_installed( - requirements_file: Path, installed_packages: Dict[str, str] -) -> List[Dict[str, str]]: - """Compares requirements file (PyPI names) against installed packages.""" - results = [] - if not requirements_file.is_file(): - logger.warning(f"Compare failed: '{requirements_file}' not found.") - return results - logger.info(f"Comparing '{requirements_file.name}' with installed packages...") - line_num = 0 - with open(requirements_file, "r", encoding="utf-8") as f: - for line in f: - line_num += 1 - spec = line.strip() - if not spec or spec.startswith("#") or spec.startswith("-"): - continue - - pypi_spec = spec - pkg_name = pypi_spec - req_str = "any" - req_obj = None - if PACKAGING_AVAILABLE: - try: - req_obj = Requirement(pypi_spec) - pkg_name = req_obj.name - req_str = str(req_obj.specifier) if req_obj.specifier else "any" - except Exception as e: - logger.warning( - f"L{line_num}: Failed to parse '{pypi_spec}' with packaging: {e}." - ) - match = re.match(r"([a-zA-Z0-9._-]+)", pypi_spec) - if match: - pkg_name = match.group(1) - else: # Basic fallback - m_spec = re.match( - r"([a-zA-Z0-9._-]+)\s*([<>=!~]=?)\s*([0-9a-zA-Z.]+)", pypi_spec - ) - m_name = re.match(r"([a-zA-Z0-9._-]+)", pypi_spec) - if m_spec: - pkg_name, req_str = m_spec.group(1), m_spec.group(2) + m_spec.group( - 3 - ) - elif m_name: - pkg_name = m_name.group(1) - else: - logger.warning( - f"L{line_num}: Could not extract package name from '{pypi_spec}'. Skipping comparison." - ) - continue # Skip if name cannot be extracted - - norm_name = pkg_name.lower().replace("_", "-") - inst_ver = installed_packages.get(norm_name) - status = "" - item = { - "package": pkg_name, - "required": req_str, - "installed": inst_ver or "Not installed", - } - if inst_ver: - if PACKAGING_AVAILABLE and req_obj and req_obj.specifier: - try: - if parse_version(inst_ver) in req_obj.specifier: - status = "OK" - else: - status = "Mismatch" - except InvalidVersion: - logger.warning( - f"Package '{pkg_name}': Installed version '{inst_ver}' is invalid." - ) - status = "Invalid Installed Version" - except Exception as e: - logger.error(f"Error comparing version for '{pkg_name}': {e}") - status = "Compare Error" - elif ( - req_str != "any" and "==" in req_str - ): # Basic == check if no packaging - expected_ver = req_str.split("==", 1)[1].strip() - status = "OK" if inst_ver == expected_ver else "Mismatch" - else: - status = ( - "Installed" # Version check skipped or not required by spec - ) - else: - status = "Not installed" - item["status"] = status - results.append(item) - - logger.info(f"Comparison finished: {len(results)} entries.") - return results - - -def update_system_packages( - packages_to_update: List[str], repo_path: Path -) -> Tuple[bool, str]: - """Updates packages using pip install --upgrade -r temp_file.""" - if not packages_to_update: - return True, "No packages specified for update." - req_file = repo_path / "requirements.txt" - if not req_file.is_file(): - return False, f"Cannot update: '{req_file}' not found." - - temp_req_lines: List[str] = [] - req_map: Dict[str, str] = {} # normalized_name -> pip_spec_line - try: - with open(req_file, "r", encoding="utf-8") as f: - for line in f: - spec = line.strip() - if not spec or spec.startswith("#") or spec.startswith("-"): - continue - name = spec # Default - if PACKAGING_AVAILABLE: - try: - req = Requirement(spec) - name = req.name - except: - match = re.match(r"([a-zA-Z0-9._-]+)", spec) - if match: - name = match.group(1) - else: - match = re.match(r"([a-zA-Z0-9._-]+)", spec) - if match: - name = match.group(1) - if name: - req_map[name.lower().replace("_", "-")] = spec - except Exception as e: - return False, f"Error reading '{req_file}': {e}" - - for pkg_update_name in packages_to_update: - norm_name = pkg_update_name.lower().replace("_", "-") - if norm_name in req_map: - temp_req_lines.append(req_map[norm_name]) # Use spec from file - else: - logger.warning( - f"'{pkg_update_name}' not in requirements, upgrading by name." - ) - temp_req_lines.append(pkg_update_name) - - if not temp_req_lines: - return True, "No matching packages found in requirements to update." - - temp_req_path = repo_path / "_temp_update_reqs.txt" - cmd = [ - sys.executable, - "-m", - "pip", - "install", - "--upgrade", - "-r", - str(temp_req_path.resolve()), - "--disable-pip-version-check", - ] - proc = None - try: - with open(temp_req_path, "w", encoding="utf-8") as f: - f.write("\n".join(temp_req_lines) + "\n") - logger.info( - f"Attempting to update {len(temp_req_lines)} package(s) via: {' '.join(cmd)}" - ) - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="ignore", - creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, - ) - stdout, stderr = proc.communicate(timeout=600) - if proc.returncode == 0: - msg = f"Update completed for: {', '.join(packages_to_update)}." - logger.info(msg) - if stderr and stderr.strip(): - logger.warning(f"Update stderr:\n{stderr.strip()}") - return True, msg - else: - raise Exception( - f"Pip error (code {proc.returncode}):\nStderr: {stderr.strip()}\nStdout: {stdout.strip()}" - ) # Include stdout - except Exception as e: - error_msg = f"Update failed: {str(e)}" - logger.error(error_msg) - if isinstance(e, subprocess.TimeoutExpired) and proc: - proc.kill() - proc.wait() - return False, error_msg - finally: - if temp_req_path.exists(): - try: - os.remove(temp_req_path) - logger.debug(f"Removed temp file: {temp_req_path}") - except OSError as e: - logger.warning(f"Could not remove temp file '{temp_req_path}': {e}") diff --git a/dependencyanalyzer/core/__init__.py b/dependencyanalyzer/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dependencyanalyzer/core/analyzer.py b/dependencyanalyzer/core/analyzer.py new file mode 100644 index 0000000..e066b1c --- /dev/null +++ b/dependencyanalyzer/core/analyzer.py @@ -0,0 +1,210 @@ +# dependency_analyzer/core/analyzer.py +""" +Core static analysis functionality for Python projects. + +This module provides the tools to parse Python source files, extract +import statements, and classify them as standard library, external, +or project-internal modules. +""" +import ast +import importlib.metadata +import logging +import os +from pathlib import Path +from typing import Dict, Set, Tuple, Union, Optional + +from .stdlib_detector import is_standard_library + +# --- Logger Configuration --- +logger = logging.getLogger(__name__) + +# --- Type Aliases --- +# Structure: {'pypi_name': {'locations': {'file1', 'file2'}, 'version': '1.2.3', ...}} +DependencyInfo = Dict[str, Dict[str, Union[Set[str], Optional[str], str]]] + +# --- Constants --- +# Maps common import names to their actual PyPI package names. +MODULE_NAME_TO_PACKAGE_NAME_MAP: Dict[str, str] = { + "PIL": "Pillow", + "cv2": "opencv-python", + # Add other common mappings here if needed +} + +# Modules that are often incorrectly identified as external. +FALSE_POSITIVE_EXTERNAL_MODULES: Set[str] = {"mpl_toolkits"} + + +class ImportExtractor(ast.NodeVisitor): + """ + An AST (Abstract Syntax Tree) visitor that extracts top-level module imports + from a Python source file. + """ + + def __init__(self, file_path_str: str): + """ + Initializes the visitor. + Args: + file_path_str: The relative path to the file being analyzed, + used for tracking where modules are imported. + """ + super().__init__() + self.file_path_str = file_path_str + self.imported_modules: Set[Tuple[str, str]] = set() + + def visit_Import(self, node: ast.Import): + """Handles 'import module' statements.""" + for alias in node.names: + # We only care about the top-level package (e.g., 'os' from 'os.path') + module_name = alias.name.split(".")[0] + if module_name: + self.imported_modules.add((module_name, self.file_path_str)) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom): + """Handles 'from module import something' statements.""" + # We only consider absolute imports (level=0). Relative imports are local. + if node.module and node.level == 0: + module_name = node.module.split(".")[0] + if module_name: + self.imported_modules.add((module_name, self.file_path_str)) + + +def find_project_modules_and_dependencies( + repo_path: Path, + scan_path: Path, +) -> Tuple[DependencyInfo, DependencyInfo]: + """ + Analyzes Python files, identifies project modules, and classifies dependencies. + + This function walks through the `scan_path`, parses each Python file to find + imports, and then categorizes each import as either part of the standard + library or an external dependency. It intelligently ignores imports that + refer to the project's own modules. + + Args: + repo_path: The root path of the repository, used for reporting relative paths. + scan_path: The specific directory to start the analysis from (can be a subfolder). + + Returns: + A tuple containing two dictionaries: + - std_lib_info: Information about used standard library modules. + - external_deps_info: Information about detected external dependencies. + """ + all_imports_locations: Dict[str, Set[str]] = {} + + # If scanning a sub-directory that matches the repo name, assume it's the main package. + # Imports of this name will be ignored as project-internal. + main_project_package_name: Optional[str] = None + if repo_path != scan_path and scan_path.name == repo_path.name.lower(): + main_project_package_name = scan_path.name + logger.info( + f"Assuming '{main_project_package_name}' is the main project package." + ) + + logger.info(f"Analyzing Python files for imports in '{scan_path}'...") + excluded_dirs = { + "venv", ".venv", "env", ".env", "docs", "tests", "test", + "site-packages", "dist-packages", "__pycache__", ".git", ".hg", + ".svn", ".tox", ".nox", "build", "dist", "*.egg-info", + } + file_count = 0 + + for root, dirs, files in os.walk(scan_path, topdown=True): + # Prune the directory list to avoid walking into excluded folders + dirs[:] = [d for d in dirs if d not in excluded_dirs and not d.startswith(".")] + + current_root_path = Path(root) + for file_name in files: + if not file_name.endswith(".py"): + continue + + file_path_obj = current_root_path / file_name + file_count += 1 + + try: + # Use repo_path as the base for user-friendly relative paths + report_rel_path_str = str(file_path_obj.relative_to(repo_path)) + except ValueError: + report_rel_path_str = str(file_path_obj) + logger.warning( + f"File path '{file_path_obj}' is not relative to repo root '{repo_path}'." + ) + + logger.debug(f"Parsing: {report_rel_path_str}") + try: + with open(file_path_obj, "r", encoding="utf-8", errors="ignore") as f: + source_code = f.read() + + tree = ast.parse(source_code, filename=str(file_path_obj)) + extractor = ImportExtractor(file_path_str=report_rel_path_str) + extractor.visit(tree) + + for module, path in extractor.imported_modules: + all_imports_locations.setdefault(module, set()).add(path) + + except SyntaxError as e: + logger.warning(f"Syntax error in '{report_rel_path_str}': {e}. Skipping.") + except Exception as e: + logger.exception(f"Error processing file '{report_rel_path_str}': {e}") + + logger.info( + f"Analyzed {file_count} Python files. Found {len(all_imports_locations)} unique top-level imports." + ) + logger.info("Classifying imports and fetching package versions...") + + std_libs: DependencyInfo = {} + external_deps: DependencyInfo = {} + + for imp_module, locations in all_imports_locations.items(): + # Ignore if it matches the main project package name (project-internal import) + if main_project_package_name and imp_module == main_project_package_name: + logger.info( + f"Skipping '{imp_module}' as it matches the main project package." + ) + continue + + # Ignore known false positives + if imp_module in FALSE_POSITIVE_EXTERNAL_MODULES: + logger.info(f"Skipping known false positive: '{imp_module}'") + continue + + # Classify as standard library or external + if is_standard_library(imp_module): + logger.debug(f"'{imp_module}' classified as standard library.") + std_libs[imp_module] = {"locations": locations, "version": None} + else: + # It's an external dependency, process it + pypi_name = MODULE_NAME_TO_PACKAGE_NAME_MAP.get(imp_module, imp_module) + orig_imp = imp_module if pypi_name != imp_module else None + logger.debug( + f"'{imp_module}' (PyPI: '{pypi_name}') is external. Fetching version..." + ) + + version: Optional[str] = None + try: + version = importlib.metadata.version(pypi_name) + except importlib.metadata.PackageNotFoundError: + logger.warning( + f"Version for '{pypi_name}' not found in the current environment." + ) + + # Aggregate information for the dependency + dep_data = external_deps.setdefault( + pypi_name, + {"locations": set(), "version": version, "original_import_name": None}, + ) + dep_data["locations"].update(locations) + + # Record the original import name if it was mapped + if orig_imp and dep_data.get("original_import_name") is None: + dep_data["original_import_name"] = orig_imp # type: ignore + + # Update version if it was found now but not before (unlikely but safe) + if dep_data.get("version") is None and version is not None: + dep_data["version"] = version + + logger.info( + f"Classification complete: {len(std_libs)} stdlib modules, " + f"{len(external_deps)} external dependencies." + ) + return std_libs, external_deps \ No newline at end of file diff --git a/dependencyanalyzer/core/package_manager.py b/dependencyanalyzer/core/package_manager.py new file mode 100644 index 0000000..4e923d4 --- /dev/null +++ b/dependencyanalyzer/core/package_manager.py @@ -0,0 +1,599 @@ +# dependency_analyzer/core/package_manager.py +""" +Handles Python package management tasks. + +This module includes functions for: +- Generating and reading requirements.txt files. +- Downloading packages for offline installation using pip. +- Creating installation scripts (.bat, .sh). +- Comparing project requirements with the currently installed packages. +- Updating packages in the local environment. +""" + +import json +import logging +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from .analyzer import DependencyInfo # Import type alias for clarity + +# --- Logger Configuration --- +logger = logging.getLogger(__name__) + +# --- Optional 'packaging' library import for robust version/specifier parsing --- +try: + from packaging.requirements import Requirement + from packaging.version import InvalidVersion, parse as parse_version + + PACKAGING_AVAILABLE = True + logger.debug("Successfully imported the 'packaging' library for robust parsing.") +except ImportError: + # Define dummy classes for type hinting and checks if 'packaging' is not available + class Requirement: # type: ignore + def __init__(self, *args, **kwargs): + raise NotImplementedError + class InvalidVersion(Exception): # type: ignore + pass + parse_version = None + PACKAGING_AVAILABLE = False + logger.warning( + "'packaging' library not found. Version comparison will be less robust." + ) + + +def find_main_script(repo_path: Path) -> Optional[Path]: + """ + Finds the main executable script (__main__.py) in standard project locations. + + Args: + repo_path: The root directory of the repository. + + Returns: + The Path object to the main script if found, otherwise None. + """ + repo_name = repo_path.name + script_subfolder_name = repo_name.lower() + + # Standard location: repo_root/repo_name/__main__.py + path_in_subdir = repo_path / script_subfolder_name / "__main__.py" + # Fallback location: repo_root/__main__.py + path_in_root = repo_path / "__main__.py" + + logger.info( + f"Searching for main script at '{path_in_subdir}' or '{path_in_root}'" + ) + if path_in_subdir.is_file(): + logger.info(f"Main script found: {path_in_subdir}") + return path_in_subdir + if path_in_root.is_file(): + logger.info(f"Main script found: {path_in_root}") + return path_in_root + + logger.warning("Main script (__main__.py) not found in standard locations.") + return None + + +def generate_requirements_file( + repo_path: Path, + external_deps_info: DependencyInfo, + std_lib_deps_info: DependencyInfo, +) -> Path: + """ + Generates a detailed requirements.txt file in the repository's root. + + Args: + repo_path: The root directory of the repository. + external_deps_info: Dictionary of detected external dependencies. + std_lib_deps_info: Dictionary of detected standard library modules. + + Returns: + The path to the generated requirements.txt file. + """ + req_file_path = repo_path / "requirements.txt" + py_ver = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + logger.info(f"Generating '{req_file_path}'...") + + try: + with open(req_file_path, "w", encoding="utf-8") as f: + f.write(f"# Requirements generated by DependencyAnalyzer for {repo_path.name}\n") + f.write(f"# Python Version (analysis env): {py_ver}\n\n") + + f.write(f"# --- Standard Library Modules Used (part of Python {py_ver}) ---\n") + if not std_lib_deps_info: + f.write("# (No stdlib imports detected)\n") + else: + for name in sorted(std_lib_deps_info.keys()): + locs = std_lib_deps_info[name].get("locations", set()) + # Ensure locs is a list for consistent sorting + loc_list = sorted(list(locs)) if isinstance(locs, set) else [] + loc_str = "" + if loc_list: + # Show first 3 locations for brevity + preview = ", ".join(loc_list[:3]) + ellipsis = ", ..." if len(loc_list) > 3 else "" + loc_str = f"Used in: {preview}{ellipsis}" + f.write(f"# {name} ({loc_str})\n".replace(" ()", "")) + + f.write("\n# --- External Dependencies (for pip install) ---\n") + if not external_deps_info: + f.write("# (No external dependencies detected)\n") + else: + for pypi_name in sorted(external_deps_info.keys()): + info = external_deps_info[pypi_name] + ver = info.get("version") + orig_imp = info.get("original_import_name") + + # Construct a requirement string, e.g., "package==1.2.3" or "package" + req_line = f"{pypi_name}=={ver}" if ver else pypi_name + + locs = info.get("locations", set()) + loc_list = sorted(list(locs)) if isinstance(locs, set) else [] + + comment_lines = [] + if loc_list: + imp_as = f"(imported as '{orig_imp}') " if orig_imp else "" + preview = ", ".join(loc_list[:3]) + ellipsis = ", ..." if len(loc_list) > 3 else "" + comment_lines.append(f"# Found {imp_as}in: {preview}{ellipsis}") + + if not ver: + comment_lines.append("# Version not detected in analysis environment.") + + if comment_lines: + f.write("\n".join(comment_lines) + "\n") + f.write(f"{req_line}\n\n") + + logger.info(f"Successfully generated '{req_file_path}'.") + except IOError as e: + logger.exception(f"Error writing to '{req_file_path}': {e}") + raise + return req_file_path + + +def copy_requirements_to_packages_dir(repo_path: Path, packages_dir: Path) -> bool: + """ + Copies the main requirements.txt file to the offline packages directory. + """ + source_req = repo_path / "requirements.txt" + dest_req = packages_dir / "requirements.txt" + + if not source_req.is_file(): + logger.warning(f"Source '{source_req}' not found, cannot copy.") + return False + try: + shutil.copy2(source_req, dest_req) + logger.info(f"Copied '{source_req.name}' to '{packages_dir}'.") + return True + except (IOError, shutil.Error) as e: + logger.exception(f"Failed to copy '{source_req.name}': {e}") + return False + + +def download_packages( + repo_path: Path, requirements_file_path: Path +) -> Tuple[bool, str]: + """ + Downloads all required packages into a local '_req_packages' directory. + + Args: + repo_path: The root directory of the repository. + requirements_file_path: Path to the requirements.txt file to use. + + Returns: + A tuple (success_flag, summary_message). + """ + packages_dir = repo_path / "_req_packages" + packages_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Starting package download to '{packages_dir}'...") + + if not requirements_file_path.is_file(): + msg = f"Source requirements file '{requirements_file_path}' not found." + logger.error(msg) + return False, msg + + copy_requirements_to_packages_dir(repo_path, packages_dir) + + cmd = [ + sys.executable, "-m", "pip", "download", + "-r", str(requirements_file_path.resolve()), + "-d", str(packages_dir.resolve()), + "--no-cache-dir", + "--disable-pip-version-check", + ] + + logger.debug(f"Running command: {' '.join(cmd)}") + try: + # Using subprocess.run for a simpler blocking call + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + encoding="utf-8", + errors="ignore", + timeout=600, # 10-minute timeout + check=True, # Raises CalledProcessError on non-zero exit codes + creationflags=(subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0), + ) + if proc.stderr and proc.stderr.strip(): + logger.warning(f"Pip download reported warnings:\n{proc.stderr.strip()}") + + summary = f"All packages from '{requirements_file_path.name}' downloaded successfully." + logger.info(summary) + return True, summary + + except FileNotFoundError: + msg = f"Command not found: '{sys.executable}'. Is Python/pip in PATH?" + logger.error(msg) + return False, msg + except subprocess.TimeoutExpired: + msg = "Package download timed out after 10 minutes." + logger.error(msg) + return False, msg + except subprocess.CalledProcessError as e: + error_details = f"Pip error (code {e.returncode}):\n{e.stderr.strip()}" + logger.error(error_details) + return False, f"Failed to download packages. {error_details}" + except Exception as e: + logger.exception("An unexpected error occurred during package download.") + return False, f"An unexpected error occurred: {e}" + + +def create_install_scripts( + repo_path: Path, requirements_file_path: Path +) -> Tuple[bool, str]: + """ + Creates install_packages.bat and install_packages.sh scripts. + """ + packages_dir = repo_path / "_req_packages" + packages_dir.mkdir(parents=True, exist_ok=True) + + if not requirements_file_path.is_file(): + msg = f"Source requirements file '{requirements_file_path}' not found." + return False, msg + if not copy_requirements_to_packages_dir(repo_path, packages_dir): + return False, "Failed to copy requirements.txt to packages directory." + + req_file_rel_name = "requirements.txt" + + # Windows Batch Script + bat_script_content = f"""@echo off +cls +echo ------------------------------------ +echo Package Installer for {repo_path.name} +echo ------------------------------------ +echo. +echo Checking for a valid Python pip module... +python -m pip --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo WARNING: 'python -m pip' failed. Trying 'py -m pip'... + py -m pip --version >nul 2>&1 + if %errorlevel% neq 0 ( + goto :pip_error + ) else ( + set PYTHON_CMD=py + ) +) else ( + set PYTHON_CMD=python +) +echo Python pip found. +goto :install + +:pip_error +echo ERROR: Python pip module not found. +echo Please ensure Python is installed and 'pip' is available in your PATH. +echo See: https://pip.pypa.io/en/stable/installation/ +echo. +pause +exit /b 1 + +:install +echo. +echo Installing packages from local folder... +echo Source Directory: %~dp0 +echo. +%PYTHON_CMD% -m pip install --no-index --find-links "%~dp0" -r "%~dp0{req_file_rel_name}" --disable-pip-version-check +if %errorlevel% neq 0 ( + goto :install_error +) + +echo. +echo -------------------------- +echo Installation Successful! +echo -------------------------- +echo. +pause +exit /b 0 + +:install_error +echo. +echo -------------------------- +echo ERROR: Installation Failed. +echo -------------------------- +echo Please check the messages above for details. +pause +exit /b 1 +""" + + # Linux/macOS Shell Script + sh_script_content = f"""#!/bin/sh +# Shell script for installing packages on Linux/macOS + +echo "------------------------------------" +echo "Package Installer for {repo_path.name}" +echo "------------------------------------" +echo "" +echo "Checking for a valid Python pip module..." + +# Find a valid python command +if command -v python3 >/dev/null 2>&1 && python3 -m pip --version >/dev/null 2>&1; then + PYTHON_CMD="python3" +elif command -v python >/dev/null 2>&1 && python -m pip --version >/dev/null 2>&1; then + PYTHON_CMD="python" +else + echo "ERROR: Could not find a working Python pip installation for 'python' or 'python3'." + echo "Please ensure Python and pip are correctly installed." + echo "See: https://pip.pypa.io/en/stable/installation/" + exit 1 +fi +echo "Found pip using: '$PYTHON_CMD'" + +# Get the directory where the script is located +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REQ_FILE="$SCRIPT_DIR/{req_file_rel_name}" + +echo "" +echo "Installing packages from: '$SCRIPT_DIR'" +echo "Using requirements file: '$REQ_FILE'" +echo "" + +"$PYTHON_CMD" -m pip install --no-index --find-links "$SCRIPT_DIR" -r "$REQ_FILE" --disable-pip-version-check +INSTALL_STATUS=$? + +echo "" +if [ $INSTALL_STATUS -ne 0 ]; then + echo "--------------------------" + echo "ERROR: Installation Failed." + echo "--------------------------" + echo "Please check the error messages above." + exit 1 +else + echo "--------------------------" + echo "Installation Successful!" + echo "--------------------------" + exit 0 +fi +""" + try: + bat_path = packages_dir / "install_packages.bat" + sh_path = packages_dir / "install_packages.sh" + + with open(bat_path, "w", encoding="utf-8", newline="\r\n") as f: + f.write(bat_script_content) + logger.info(f"Created Windows install script: {bat_path}") + + with open(sh_path, "w", encoding="utf-8", newline="\n") as f: + f.write(sh_script_content) + os.chmod(sh_path, 0o755) # Make the script executable on Unix-like systems + logger.info(f"Created Linux/macOS install script: {sh_path} (executable)") + + return True, f"Installation scripts created in '{packages_dir}'." + except IOError as e: + logger.exception("Error creating installation scripts.") + return False, f"Error creating scripts: {e}" + + +def get_installed_packages() -> Dict[str, str]: + """ + Retrieves all packages installed in the current Python environment. + + Returns: + A dictionary mapping normalized package names to their versions. + """ + installed: Dict[str, str] = {} + cmd = [ + sys.executable, "-m", "pip", "list", + "--format=json", "--disable-pip-version-check", + ] + logger.debug(f"Getting installed packages using command: {' '.join(cmd)}") + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + check=True, + encoding="utf-8", + errors="ignore", + creationflags=(subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0), + ) + packages = json.loads(proc.stdout) + for pkg in packages: + # Normalize names to lowercase and hyphens for reliable matching + normalized_name = pkg["name"].lower().replace("_", "-") + installed[normalized_name] = pkg["version"] + logger.info(f"Found {len(installed)} installed packages in the environment.") + except FileNotFoundError: + logger.error(f"Command not found: '{cmd[0]}'. Is Python/pip in PATH?") + except subprocess.CalledProcessError as e: + logger.error(f"'pip list' command failed (code {e.returncode}): {e.stderr}") + except json.JSONDecodeError as e: + logger.error(f"Failed to parse 'pip list' JSON output: {e}") + except Exception as e: + logger.exception("Unexpected error getting installed packages.") + + return installed + + +def _extract_pkg_name_from_spec(spec: str) -> Optional[str]: + """ + Helper to extract a package name from a requirement spec string. + """ + if PACKAGING_AVAILABLE: + try: + return Requirement(spec).name + except Exception: + # Fallback to regex if 'packaging' fails on a malformed line + pass + + # Basic regex fallback for when 'packaging' is not available or fails + match = re.match(r"([a-zA-Z0-9._-]+)", spec) + return match.group(1) if match else None + + +def compare_requirements_with_installed( + requirements_file: Path, installed_packages: Dict[str, str] +) -> List[Dict[str, str]]: + """ + Compares a requirements file against the dictionary of installed packages. + """ + results: List[Dict[str, str]] = [] + if not requirements_file.is_file(): + logger.warning(f"Comparison failed: '{requirements_file}' not found.") + return results + + logger.info(f"Comparing '{requirements_file.name}' with installed packages...") + with open(requirements_file, "r", encoding="utf-8") as f: + for line_num, line in enumerate(f, 1): + spec = line.strip() + if not spec or spec.startswith("#") or spec.startswith("-"): + continue + + pkg_name = _extract_pkg_name_from_spec(spec) + if not pkg_name: + logger.warning(f"L{line_num}: Could not extract package name from '{spec}'. Skipping.") + continue + + required_spec = "any" + req_obj = None + if PACKAGING_AVAILABLE: + try: + req_obj = Requirement(spec) + # Use str() to handle complex specifiers like '>=1.0,<2.0' + required_spec = str(req_obj.specifier) if req_obj.specifier else "any" + except Exception as e: + logger.warning(f"L{line_num}: Could not parse '{spec}' with 'packaging': {e}") + elif "==" in spec: + required_spec = spec.split("==", 1)[1].strip() + + norm_name = pkg_name.lower().replace("_", "-") + installed_version = installed_packages.get(norm_name) + + status = "" + if installed_version: + if PACKAGING_AVAILABLE and req_obj and req_obj.specifier: + try: + if parse_version(installed_version) in req_obj.specifier: + status = "OK" + else: + status = "Version Mismatch" + except InvalidVersion: + status = "Invalid Installed Version" + elif "==" in required_spec: # Basic check if packaging not available + status = "OK" if installed_version == required_spec else "Version Mismatch" + else: + status = "Installed (Version not specified)" + else: + status = "Not Installed" + + results.append({ + "package": pkg_name, + "required": required_spec, + "installed": installed_version or "Not Installed", + "status": status, + }) + + logger.info(f"Comparison finished with {len(results)} entries.") + return results + + +def update_system_packages( + packages_to_update: List[str], repo_path: Path +) -> Tuple[bool, str]: + """ + Updates or installs a list of packages using pip install --upgrade. + It reads the full specifier from the project's requirements file. + """ + if not packages_to_update: + return True, "No packages were specified for update." + + req_file_path = repo_path / "requirements.txt" + if not req_file_path.is_file(): + return False, f"Cannot update: '{req_file_path}' not found." + + # Create a map of {normalized_name: original_spec_line} from requirements.txt + req_map: Dict[str, str] = {} + try: + with open(req_file_path, "r", encoding="utf-8") as f: + for line in f: + spec = line.strip() + if not spec or spec.startswith("#") or spec.startswith("-"): + continue + pkg_name = _extract_pkg_name_from_spec(spec) + if pkg_name: + req_map[pkg_name.lower().replace("_", "-")] = spec + except IOError as e: + return False, f"Error reading '{req_file_path}': {e}" + + # Build a list of specs to pass to pip, based on the user's selection + final_specs_to_update: List[str] = [] + for pkg_name in packages_to_update: + norm_name = pkg_name.lower().replace("_", "-") + if norm_name in req_map: + final_specs_to_update.append(req_map[norm_name]) + else: + # Fallback: if not in requirements, just use the name + logger.warning( + f"'{pkg_name}' not found in requirements.txt. Upgrading by name only." + ) + final_specs_to_update.append(pkg_name) + + if not final_specs_to_update: + return True, "No matching packages found in requirements.txt to update." + + # Use a temporary file to handle complex specifiers safely + temp_req_path = repo_path / "_temp_update_reqs.txt" + cmd = [ + sys.executable, "-m", "pip", "install", "--upgrade", + "-r", str(temp_req_path.resolve()), + "--disable-pip-version-check", + ] + + try: + with open(temp_req_path, "w", encoding="utf-8") as f: + f.write("\n".join(final_specs_to_update)) + + logger.info(f"Attempting to update {len(final_specs_to_update)} package(s).") + logger.debug(f"Running command: {' '.join(cmd)}") + + proc = subprocess.run( + cmd, + capture_output=True, text=True, encoding="utf-8", errors="ignore", + timeout=600, check=True, + creationflags=(subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0), + ) + + summary = f"Update completed for: {', '.join(packages_to_update)}." + logger.info(summary) + if proc.stderr and proc.stderr.strip(): + logger.warning(f"Update process reported warnings:\n{proc.stderr.strip()}") + return True, summary + + except subprocess.CalledProcessError as e: + error_msg = f"Update failed. Pip error (code {e.returncode}):\n{e.stderr.strip()}" + logger.error(error_msg) + return False, error_msg + except Exception as e: + logger.exception("An unexpected error occurred during package update.") + return False, f"An unexpected error occurred: {e}" + finally: + # Clean up the temporary file + if temp_req_path.exists(): + try: + os.remove(temp_req_path) + except OSError as e: + logger.warning(f"Could not remove temporary file '{temp_req_path}': {e}") \ No newline at end of file diff --git a/dependencyanalyzer/core/project_normalizer.py b/dependencyanalyzer/core/project_normalizer.py new file mode 100644 index 0000000..fb502fa --- /dev/null +++ b/dependencyanalyzer/core/project_normalizer.py @@ -0,0 +1,209 @@ +# dependency_analyzer/core/project_normalizer.py +""" +Handles the logic for normalizing a Python project structure. + +This includes creating virtual environments, generating configuration files, +and installing dependencies in an isolated manner. +""" +import logging +import subprocess +import sys +from pathlib import Path +from typing import Dict, List, Tuple + +# Import functions from other core modules that we will reuse +from . import analyzer, package_manager + +# --- Logger Configuration --- +logger = logging.getLogger(__name__) + + +def check_project_status(project_path: Path) -> Dict[str, bool]: + """ + Analyzes a project directory to check for key normalization indicators. + """ + if not project_path or not project_path.is_dir(): + return {'has_venv': False, 'has_requirements': False, 'has_pyproject': False} + + status = { + 'has_venv': (project_path / '.venv').is_dir(), + 'has_requirements': (project_path / 'requirements.txt').is_file(), + 'has_pyproject': (project_path / 'pyproject.toml').is_file(), + } + logger.info(f"Project status check for '{project_path.name}': {status}") + return status + + +def _run_command( + command: List[str], cwd: Path, description: str +) -> Tuple[bool, str]: + """ + A helper to run a shell command and return its status and output. + + Args: + command: The command to run as a list of strings. + cwd: The working directory for the command. + description: A brief description of the action for logging. + + Returns: + A tuple (success_flag, message). + """ + logger.info(f"Running task: {description}...") + logger.debug(f"Executing command: {' '.join(command)}") + try: + proc = subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="ignore", + check=True, # Raise an exception for non-zero exit codes + timeout=600, # 10-minute timeout + creationflags=(subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0), + ) + message = f"Task '{description}' completed successfully." + if proc.stdout and proc.stdout.strip(): + logger.debug(f"Output from '{description}':\n{proc.stdout.strip()}") + return True, message + except FileNotFoundError: + msg = f"Error: Command '{command[0]}' not found. Is Python correctly installed and in PATH?" + logger.error(msg) + return False, msg + except subprocess.TimeoutExpired: + msg = f"Error: Task '{description}' timed out after 10 minutes." + logger.error(msg) + return False, msg + except subprocess.CalledProcessError as e: + error_details = e.stderr.strip() or e.stdout.strip() + msg = f"Error during '{description}':\n{error_details}" + logger.error(msg) + return False, msg + except Exception as e: + msg = f"An unexpected error occurred during '{description}': {e}" + logger.exception(msg) + return False, msg + + +def _get_venv_python_path(project_path: Path) -> Path: + """ + Determines the path to the Python executable inside the .venv directory. + """ + if sys.platform == "win32": + return project_path / ".venv" / "Scripts" / "python.exe" + else: + return project_path / ".venv" / "bin" / "python" + + +def normalize_project(project_path: Path) -> Dict[str, Tuple[bool, str]]: + """ + Executes the full project normalization workflow. + + Args: + project_path: The root path of the project to normalize. + + Returns: + A dictionary containing the success status and a message for each step. + """ + results: Dict[str, Tuple[bool, str]] = {} + + # --- Step 1: Create Virtual Environment --- + venv_path = project_path / ".venv" + if venv_path.exists(): + logger.info("Virtual environment '.venv' already exists. Skipping creation.") + results["venv_creation"] = (True, "Virtual environment '.venv' already exists.") + else: + success, message = _run_command( + [sys.executable, "-m", "venv", ".venv"], + cwd=project_path, + description="Create virtual environment", + ) + results["venv_creation"] = (success, message) + if not success: + return results # Stop if venv creation fails + + # --- Step 2: Analyze Dependencies --- + logger.info("Analyzing project dependencies using AST...") + try: + # Determine scan path (same logic as in the GUI) + scan_path = project_path + potential_sub_pkg = project_path / project_path.name.lower() + if potential_sub_pkg.is_dir(): + scan_path = potential_sub_pkg + + std_lib_info, external_info = analyzer.find_project_modules_and_dependencies( + repo_path=project_path, scan_path=scan_path + ) + results["analysis"] = (True, f"Analysis found {len(external_info)} external dependencies.") + except Exception as e: + msg = f"Failed to analyze project dependencies: {e}" + logger.exception(msg) + results["analysis"] = (False, msg) + return results + + # --- Step 3: Generate requirements.txt --- + logger.info("Generating requirements.txt file...") + try: + req_file = package_manager.generate_requirements_file( + project_path, external_info, std_lib_info + ) + results["requirements_generation"] = (True, f"Successfully created '{req_file.name}'.") + except Exception as e: + msg = f"Failed to generate requirements.txt: {e}" + logger.exception(msg) + results["requirements_generation"] = (False, msg) + return results + + # --- Step 4: Install Dependencies into .venv --- + if not external_info: + logger.info("No external dependencies to install.") + results["dependency_installation"] = (True, "No external dependencies found to install.") + else: + venv_python = _get_venv_python_path(project_path) + success, message = _run_command( + [str(venv_python), "-m", "pip", "install", "-r", "requirements.txt"], + cwd=project_path, + description="Install dependencies into .venv", + ) + results["dependency_installation"] = (success, message) + if not success: + return results # Stop if installation fails + + # --- Step 5: Create pyproject.toml --- + pyproject_path = project_path / "pyproject.toml" + if pyproject_path.exists(): + logger.info("'pyproject.toml' already exists. Skipping creation.") + results["pyproject_creation"] = (True, "'pyproject.toml' already exists.") + else: + logger.info("Creating a minimal pyproject.toml file...") + project_name = project_path.name.lower().replace("_", "-") + pyproject_content = f"""[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "{project_name}" +version = "0.1.0" +description = "A short description of the project." +readme = "README.md" +requires-python = ">=3.8" +# Add other classifiers or dependencies here if needed +# dependencies = [ +# "dependency1", +# "dependency2", +# ] + +[project.urls] +Homepage = "https://example.com" +""" + try: + pyproject_path.write_text(pyproject_content, encoding="utf-8") + msg = "Successfully created a minimal 'pyproject.toml'." + logger.info(msg) + results["pyproject_creation"] = (True, msg) + except IOError as e: + msg = f"Failed to write 'pyproject.toml': {e}" + logger.error(msg) + results["pyproject_creation"] = (False, msg) + + return results \ No newline at end of file diff --git a/dependencyanalyzer/core/stdlib_detector.py b/dependencyanalyzer/core/stdlib_detector.py new file mode 100644 index 0000000..33e403c --- /dev/null +++ b/dependencyanalyzer/core/stdlib_detector.py @@ -0,0 +1,257 @@ +# dependency_analyzer/core/stdlib_detector.py +""" +Handles the detection of Python's standard library modules. + +This module provides functions to determine if a module name or file path +belongs to the Python standard library, accommodating different Python versions. +""" +import importlib.util +import logging +import os +import sys +import sysconfig +from pathlib import Path +from typing import Optional, Set + +# --- Logger Configuration --- +logger = logging.getLogger(__name__) + +# --- Standard Library Detection Logic --- +# Attempt to get the frozenset of stdlib modules, introduced in Python 3.10 +if sys.version_info >= (3, 10): + try: + # The most reliable method for modern Python versions + STANDARD_LIBRARY_MODULES: frozenset[str] = sys.stdlib_module_names + logger.debug( + "Using sys.stdlib_module_names for standard library module list (Python 3.10+)." + ) + except AttributeError: + # Fallback in case the attribute is missing for some reason + logger.warning( + "sys.stdlib_module_names not found despite Python 3.10+. Using a predefined list." + ) + STANDARD_LIBRARY_MODULES = frozenset() # Will be populated by the predefined list +else: + # For Python versions older than 3.10 + logger.debug( + "Using a predefined list for standard library modules (Python < 3.10)." + ) + STANDARD_LIBRARY_MODULES = frozenset() # Will be populated below + +# If the modern method is unavailable or failed, use a hardcoded list. +# This list is extensive but might not be perfectly up-to-date. +if not STANDARD_LIBRARY_MODULES: + _PREDEFINED_STDLIBS = { + "abc", "aifc", "argparse", "array", "ast", "asynchat", "asyncio", + "asyncore", "atexit", "audioop", "base64", "bdb", "binascii", + "binhex", "bisect", "builtins", "bz2", "calendar", "cgi", "cgitb", + "chunk", "cmath", "cmd", "code", "codecs", "codeop", "collections", + "colorsys", "compileall", "concurrent", "configparser", "contextlib", + "contextvars", "copy", "copyreg", "cProfile", "crypt", "csv", + "ctypes", "curses", "dataclasses", "datetime", "dbm", "decimal", + "difflib", "dis", "distutils", "doctest", "email", "encodings", + "ensurepip", "enum", "errno", "faulthandler", "fcntl", "filecmp", + "fileinput", "fnmatch", "formatter", "fractions", "ftplib", + "functools", "gc", "getopt", "getpass", "gettext", "glob", + "graphlib", "grp", "gzip", "hashlib", "heapq", "hmac", "html", + "http", "idlelib", "imaplib", "imghdr", "imp", "importlib", + "inspect", "io", "ipaddress", "itertools", "json", "keyword", + "lib2to3", "linecache", "locale", "logging", "lzma", "mailbox", + "mailcap", "marshal", "math", "mimetypes", "mmap", "modulefinder", + "multiprocessing", "netrc", "nis", "nntplib", "numbers", "operator", + "optparse", "os", "ossaudiodev", "parser", "pathlib", "pdb", + "pickle", "pickletools", "pipes", "pkgutil", "platform", "plistlib", + "poplib", "posix", "pprint", "profile", "pstats", "pty", "pwd", + "py_compile", "pyclbr", "pydoc", "pydoc_data", "pyexpat", "queue", + "quopri", "random", "re", "readline", "reprlib", "resource", + "rlcompleter", "runpy", "sched", "secrets", "select", "selectors", + "shelve", "shlex", "shutil", "signal", "site", "smtpd", "smtplib", + "sndhdr", "socket", "socketserver", "spwd", "sqlite3", "ssl", + "stat", "statistics", "string", "stringprep", "struct", + "subprocess", "sunau", "symtable", "sys", "sysconfig", "syslog", + "tabnanny", "tarfile", "telnetlib", "tempfile", "termios", + "textwrap", "threading", "time", "timeit", "tkinter", "token", + "tokenize", "trace", "traceback", "tracemalloc", "tty", "turtle", + "turtledemo", "types", "typing", "unicodedata", "unittest", + "urllib", "uu", "uuid", "venv", "warnings", "wave", "weakref", + "webbrowser", "wsgiref", "xdrlib", "xml", "xmlrpc", "zipapp", + "zipfile", "zipimport", "zlib", "_thread", "_collections_abc", + "_json", "_datetime", "_weakrefset", "_strptime", "_socket", "_ssl", + "_struct", "_queue", "_pickle", "_lsprof", "_heapq", "_hashlib", + "_csv", "_bz2", "_codecs", "_bisect", "_blake2", "_asyncio", "_ast", + "_abc", + } + STANDARD_LIBRARY_MODULES = frozenset(_PREDEFINED_STDLIBS) + logger.debug("Populated standard library list from predefined set.") + + +_CACHED_STD_LIB_PATHS: Optional[Set[str]] = None + + +def get_standard_library_paths() -> Set[str]: + """ + Retrieves and caches normalized paths for the Python standard library. + This is a fallback/supplement to the module name list. + """ + global _CACHED_STD_LIB_PATHS + if _CACHED_STD_LIB_PATHS is not None: + return _CACHED_STD_LIB_PATHS + + paths: Set[str] = set() + logger.debug("Determining standard library paths for the first time...") + try: + # Common paths from sysconfig + for path_name in ("stdlib", "platstdlib"): + try: + path_val = sysconfig.get_path(path_name) + if path_val and os.path.isdir(path_val): + paths.add(os.path.normpath(path_val)) + logger.debug(f"Found stdlib path ({path_name}): {path_val}") + except Exception as e: + logger.warning( + f"Could not get sysconfig path '{path_name}': {e}" + ) + + # Path relative to the Python executable + prefix_lib_path = os.path.normpath( + os.path.join( + sys.prefix, + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + ) + ) + if os.path.isdir(prefix_lib_path): + paths.add(prefix_lib_path) + logger.debug(f"Found stdlib path (prefix): {prefix_lib_path}") + + # Platform-specific paths + if sys.platform == "win32": + dlls_path = os.path.join(sys.prefix, "DLLs") + if os.path.isdir(dlls_path): + paths.add(os.path.normpath(dlls_path)) + logger.debug(f"Found stdlib path (DLLs): {dlls_path}") + else: + dynload_path = os.path.join( + sys.exec_prefix, + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + "lib-dynload", + ) + if os.path.isdir(dynload_path): + paths.add(os.path.normpath(dynload_path)) + logger.debug(f"Found stdlib path (dynload): {dynload_path}") + + # Framework paths (macOS) + fw_prefix = sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") + if fw_prefix and isinstance(fw_prefix, str) and os.path.isdir(fw_prefix): + fw_path = os.path.normpath( + os.path.join( + fw_prefix, + "lib", + f"python{sys.version_info.major}.{sys.version_info.minor}", + ) + ) + if os.path.isdir(fw_path): + paths.add(fw_path) + logger.debug(f"Found stdlib path (Framework): {fw_path}") + + # Fallback if sysconfig fails, using well-known module locations + if not paths: + logger.warning("Sysconfig paths failed, attempting fallback using module locations.") + try: + paths.add(os.path.normpath(os.path.dirname(os.__file__))) + except (AttributeError, TypeError): + logger.error("Could not determine path for 'os' module.") + try: + paths.add(os.path.normpath(os.path.dirname(sysconfig.__file__))) + except (AttributeError, TypeError): + logger.error("Could not determine path for 'sysconfig' module.") + + except Exception as e: + logger.exception(f"Unexpected error while determining standard library paths: {e}") + + _CACHED_STD_LIB_PATHS = {p for p in paths if p} # Filter out any empty paths + + if not _CACHED_STD_LIB_PATHS: + logger.error("Failed to determine ANY standard library paths. Path-based checks will fail.") + else: + logger.debug(f"Final cached standard library paths: {_CACHED_STD_LIB_PATHS}") + + return _CACHED_STD_LIB_PATHS + + +def is_path_in_standard_library(file_path_str: Optional[str]) -> bool: + """ + Checks if a file path string is within any known standard library directory. + Excludes 'site-packages' and 'dist-packages' explicitly. + """ + if not file_path_str: + return False + + std_lib_paths = get_standard_library_paths() + if not std_lib_paths: + return False + + try: + norm_file_path = os.path.normpath(os.path.abspath(file_path_str)) + + # Quick check to exclude third-party packages + path_parts = Path(norm_file_path).parts + if "site-packages" in path_parts or "dist-packages" in path_parts: + return False + + # Check if the file path starts with any of the identified stdlib paths + for std_path in std_lib_paths: + # Ensure the comparison is robust by checking for the directory separator + if norm_file_path.startswith(std_path + os.sep): + return True + except Exception as e: + logger.warning(f"Error during path comparison for '{file_path_str}': {e}") + return False + + return False + + +def is_standard_library(module_name: str) -> bool: + """ + Checks if a module name belongs to the standard library using multiple strategies. + + 1. Checks against a predefined/system-provided list of names. + 2. Finds the module's specification (`find_spec`) to check its origin. + 3. If the origin is a file path, checks if it's in a standard library directory. + """ + # Strategy 1: Check against the reliable name list + if module_name in STANDARD_LIBRARY_MODULES: + logger.debug(f"'{module_name}' is in the standard library name set.") + return True + + # Strategy 2: Use importlib to find the module's specification + try: + spec = importlib.util.find_spec(module_name) + except (ValueError, ModuleNotFoundError, Exception) as e: + # Catching specific errors and a general Exception is safer than a bare except. + # ValueError can occur for invalid names, ModuleNotFoundError for non-existent parents. + logger.debug( + f"Could not find spec for '{module_name}' (error: {e}). Assuming it's non-standard." + ) + return False + + if spec is None: + logger.debug(f"No spec found for '{module_name}'. Assuming non-standard.") + return False + + origin = spec.origin + logger.debug(f"Module '{module_name}' has origin: '{origin}'") + + # 'built-in' or 'frozen' modules are part of the standard library + if origin in ("built-in", "frozen"): + logger.debug(f"'{module_name}' is a '{origin}' module.") + return True + + # Strategy 3: Check if the module's file path is in a stdlib directory + if is_path_in_standard_library(origin): + logger.debug(f"Path for '{module_name}' ('{origin}') is a standard library path.") + return True + + logger.debug(f"'{module_name}' is not classified as standard library.") + return False \ No newline at end of file diff --git a/dependencyanalyzer/gui.py b/dependencyanalyzer/gui.py index 83229c8..45bee86 100644 --- a/dependencyanalyzer/gui.py +++ b/dependencyanalyzer/gui.py @@ -1,344 +1,277 @@ # dependencyanalyzer/gui.py -import tkinter as tk -from tkinter import filedialog, messagebox, ttk +import logging +import re +import sys import threading +import tkinter as tk from pathlib import Path -import re # For parsing requirements.txt in fallback -import logging # Import standard logging -import queue # For potential use in handler, though root.after is often sufficient -from typing import Optional, List, Dict, Set, Tuple, Any, Callable - -# Assuming core.py is in the same directory or package -from . import core # Imports core, core.logger should be available via this - - -# --- Custom Logging Handler --- -class TextHandler(logging.Handler): - """ - A logging handler that writes records to a Tkinter Text widget. - Uses root.after(0, ...) to ensure thread safety for GUI updates. - """ - - def __init__(self, text_widget: tk.Text): - super().__init__() - self.text_widget = text_widget - # Basic formatter, can be customized - 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(): - """Internal function to append message in the GUI thread.""" - if self.text_widget.winfo_exists(): # Check if widget still exists - self.text_widget.config(state=tk.NORMAL) - self.text_widget.insert(tk.END, msg + "\n") - self.text_widget.see(tk.END) # Scroll to the end - self.text_widget.config(state=tk.DISABLED) - self.text_widget.update_idletasks() # Process pending events - - try: - self.text_widget.after(0, append_message) - except Exception as e: - # Fallback or logging if `after` fails (e.g., window destroyed during shutdown) - print(f"Error updating log widget: {e}", file=sys.stderr) +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: - # Use absolute import based on package name 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: - # This might happen if you run the wrapper directly from source - # without generating its _version.py first (if you use that approach for the wrapper itself) WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)" WRAPPER_BUILD_INFO = "Wrapper build time unknown" -# --- End Import Version Info --- -# --- Constants for Version Generation --- -DEFAULT_VERSION = "0.0.0+unknown" -DEFAULT_COMMIT = "Unknown" -DEFAULT_BRANCH = "Unknown" -# --- End Constants --- + +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 application for Python dependency analysis. - """ - + """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 Dependency Analyzer - {WRAPPER_APP_VERSION_STRING}" - ) + 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: core.DependencyInfo = {} - self.external_deps_info: core.DependencyInfo = {} + 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() # Setup the custom handler + self._setup_logging_handler() self._update_button_states() def _create_widgets(self) -> None: """Creates and lays out the GUI widgets.""" - # --- Repository Selection --- - repo_frame = ttk.LabelFrame( - self, text="1. Repository Operations", padding=(10, 5) - ) - repo_frame.pack(fill=tk.X, pady=5) + # --- 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 Repository Folder", command=self._select_repository + 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) - # --- Analysis --- - analysis_frame = ttk.LabelFrame( - self, text="2. Analysis & Requirements", padding=(10, 5) - ) - analysis_frame.pack(fill=tk.X, pady=5) - self.analyze_button = ttk.Button( - analysis_frame, - text="Analyze Project & Generate requirements.txt", - command=self._analyze_and_generate_reqs_threaded, - ) - self.analyze_button.pack(side=tk.LEFT, padx=5, pady=5) + self.analyzer_tab = ttk.Frame(self.notebook, padding=(10, 10)) + self.normalizer_tab = ttk.Frame(self.notebook, padding=(10, 10)) - # --- External Dependencies TreeView --- - modules_frame = ttk.LabelFrame( - self, text="3. External Dependencies Found", padding=(10, 5) - ) - modules_frame.pack(fill=tk.BOTH, expand=True, pady=5) - 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) + 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) - # --- Download & Install Scripts --- - download_frame = ttk.LabelFrame( - self, text="4. Download Packages & Create Installers", padding=(10, 5) - ) - download_frame.pack(fill=tk.X, pady=5) - self.download_button = ttk.Button( - download_frame, - text="Download Packages to _req_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_packages.bat/sh", - command=self._create_install_scripts_threaded, - ) - self.create_scripts_button.pack(side=tk.LEFT, padx=5, pady=5) - - # --- Comparison Frame --- - compare_frame = ttk.LabelFrame( - self, 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 requirements.txt with Installed Packages", - command=self._compare_packages_threaded, - ) - self.compare_button.pack(side=tk.TOP, anchor=tk.NW, padx=5, pady=5) - self.comparison_tree = ttk.Treeview( - compare_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=180, stretch=tk.NO, anchor=tk.W) - self.comparison_tree.heading("installed", text="Installed Version") - self.comparison_tree.column("installed", width=180, stretch=tk.NO, anchor=tk.W) - self.comparison_tree.heading("status", text="Status") - self.comparison_tree.column("status", width=200, stretch=tk.YES, anchor=tk.W) - compare_scrollbar_y = ttk.Scrollbar( - compare_frame, orient="vertical", command=self.comparison_tree.yview - ) - self.comparison_tree.configure(yscrollcommand=compare_scrollbar_y.set) - compare_scrollbar_x = ttk.Scrollbar( - compare_frame, orient="horizontal", command=self.comparison_tree.xview - ) - self.comparison_tree.configure(xscrollcommand=compare_scrollbar_x.set) - compare_scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y) - compare_scrollbar_x.pack(side=tk.BOTTOM, fill=tk.X) - self.comparison_tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - self.update_system_button = ttk.Button( - compare_frame, - text="Update Selected Mismatched/Not Installed Packages", - command=self._update_system_packages_threaded, - ) - self.update_system_button.pack(side=tk.TOP, anchor=tk.NW, padx=5, pady=5) - - # --- Log Area Widget --- + # --- 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=10, - state=tk.DISABLED, - wrap=tk.WORD, - relief=tk.SUNKEN, - borderwidth=1, - ) # Increased height + 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): - """Creates and adds the custom TextHandler to the root logger.""" text_handler = TextHandler(self.log_text) - text_handler.setLevel( - logging.INFO - ) # Handler level - messages below this won't pass - + text_handler.setLevel(logging.INFO) root_logger = logging.getLogger() - # Ensure root logger's level is low enough (set in __main__.py or here) - if ( - root_logger.level > logging.INFO - ): # If root logger is set to WARNING or higher - root_logger.setLevel( - logging.INFO - ) # Lower it to allow INFO messages through - print(f"Root logger level adjusted to INFO by GUI setup.") - if not any(isinstance(h, TextHandler) for h in root_logger.handlers): root_logger.addHandler(text_handler) - print("TextHandler added to root logger.") - else: - print("TextHandler already present in root logger.") - - def _log_message(self, message: str, level: str = "INFO") -> None: - """ - Logs a message using the root logger and shows popups for Warning/Error. - The TextHandler attached to the root logger will display the message in the GUI. - """ - log_level = getattr(logging, level.upper(), logging.INFO) - - # Log to the root logger, TextHandler will pick it up - logging.log(log_level, message) - - # Show popups in the GUI thread for important user feedback - if self.winfo_exists(): - if level == "ERROR": - messagebox.showerror("Error", message) - elif level == "WARNING": - messagebox.showwarning("Warning", message) def _update_button_states(self) -> None: """Enables/disables buttons based on the current state.""" repo_selected = bool(self.selected_repository_path) try: - self.analyze_button.config( - state=tk.NORMAL if repo_selected else tk.DISABLED - ) - - req_file_exists_and_valid = ( - self.requirements_file_path - and self.requirements_file_path.exists() - and bool(self.external_deps_info) - ) - - self.download_button.config( - state=tk.NORMAL if req_file_exists_and_valid else tk.DISABLED - ) - self.create_scripts_button.config( - state=( - tk.NORMAL - if req_file_exists_and_valid and repo_selected - else tk.DISABLED - ) - ) - self.compare_button.config( - state=tk.NORMAL if req_file_exists_and_valid else tk.DISABLED - ) - - can_update = ( - bool(self.comparison_tree.get_children()) and req_file_exists_and_valid - ) - self.update_system_button.config( - state=tk.NORMAL if can_update else tk.DISABLED - ) + # 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 # Ignore if widgets destroyed + pass def _select_repository(self) -> None: - """Opens a dialog to select the repository folder.""" - path = filedialog.askdirectory(title="Select Repository Folder") - if path: - self.selected_repository_path = Path(path) - self.repo_path_label.config(text=str(self.selected_repository_path)) - logging.info( - f"Repository selected: {self.selected_repository_path}" - ) # Log via root - 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()) - else: - logging.info("Repository selection cancelled.") # Log via root + """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() - # CORREZIONE APPLICATA ALLA FIRMA QUI (rimosso *) + 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, # Argomenti posizionali per task_function - # I seguenti sono opzionali e DEVONO essere passati come keyword arguments + *args_for_task: Any, callback_success: Optional[Callable[[Any], None]] = None, - callback_failure: Optional[Callable[[str], None]] = None, ) -> None: - """ - Helper to run a function in a separate thread. - `callback_success` and `callback_failure` MUST be specified as keyword arguments. - """ - 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 threaded task '{task_function.__name__}': {str(e)}" - ) - # Log error using the standard logger; TextHandler will display it. - logging.error(error_msg) # Log simple message to GUI - logging.exception( - f"Full traceback for task {task_function.__name__}:" - ) # Log traceback to console/file - if callback_failure and self.winfo_exists(): - self.master.after( - 0, lambda: callback_failure(error_msg) - ) # Pass simple message + 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) @@ -346,98 +279,61 @@ class DependencyAnalyzerApp(tk.Frame): self._temporarily_disable_buttons(True) thread = threading.Thread(target=task_wrapper, daemon=True) - logging.info( - f"Starting background task: {task_function.__name__}..." - ) # Log via root + logging.info(f"Starting background task: {task_function.__name__}...") thread.start() def _temporarily_disable_buttons(self, disable: bool = True) -> None: - """Disables or enables key operational buttons.""" 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.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(): + if hasattr(button, 'winfo_exists') and button.winfo_exists(): try: button.config(state=state_to_set) except tk.TclError: - pass # Ignore if widget destroyed + pass - # --- Analysis and Requirements Generation --- + # --- Methods for "Dependency Analyzer" Tab --- def _analyze_and_generate_reqs_threaded(self) -> None: - """Starts the background thread for analysis and requirements generation.""" if not self.selected_repository_path: - self._log_message( - "Please select a repository first.", "WARNING" - ) # User feedback + messagebox.showwarning("Warning", "Please select a project repository first.") return - # Start the background task + self._run_long_task_threaded( - self._perform_analysis_and_generation, # Function to run in thread - # No specific arguments needed here for the task function itself - callback_success=self._analysis_and_generation_callback, # Keyword arg + self._perform_analysis_and_generation, + callback_success=self._analysis_and_generation_callback, ) def _perform_analysis_and_generation( self, - ) -> Tuple[Path, core.DependencyInfo, core.DependencyInfo]: - """ - Determines scan path, performs analysis via core function, generates file. - This method runs in the background thread. - Uses self.selected_repository_path set by the GUI. - """ + ) -> Tuple[Path, analyzer.DependencyInfo, analyzer.DependencyInfo]: + """Performs analysis and generates the requirements file. Runs in a thread.""" if not self.selected_repository_path: - # This should ideally not be reached if called correctly, but added safeguard - raise ValueError( - "Repository path is not selected when analysis task started." - ) + raise ValueError("Repository path not set when analysis task started.") repo_path = self.selected_repository_path - repo_name_lower = repo_path.name.lower() - potential_sub_package_path = repo_path / repo_name_lower - - scan_path: Path # Define the path to start scanning from - - # Check if a sub-directory with the lowercased repo name exists - if potential_sub_package_path.is_dir(): - logging.info( - f"Found sub-directory '{repo_name_lower}', scanning within it." - ) - scan_path = potential_sub_package_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"Sub-directory '{repo_name_lower}' not found, scanning the selected repository root '{repo_path}'." - ) + logging.info(f"Scanning the selected project root '{repo_path}'.") scan_path = repo_path - # Optional: Find main script (less critical now but kept for potential info) - # This logic might also need adjustment based on scan_path vs repo_path if needed elsewhere - core.find_main_script(repo_path) # Still checks relative to repo_path - - # Call the core analysis function with both repo_path and scan_path - std_lib_info, external_info = core.find_project_modules_and_dependencies( - repo_path=repo_path, # Pass original root for relative paths - scan_path=scan_path, # Pass the determined path to actually scan + std_lib_info, external_info = analyzer.find_project_modules_and_dependencies( + repo_path=repo_path, scan_path=scan_path ) - - # Generate requirements file (uses repo_path for the output file location) - req_file_path = core.generate_requirements_file( - repo_path, # Output in the root selected by user - external_info, - std_lib_info, + req_file_path = package_manager.generate_requirements_file( + repo_path, external_info, std_lib_info ) - - # Return all necessary results for the callback return req_file_path, std_lib_info, external_info def _analysis_and_generation_callback( - self, result: Tuple[Path, core.DependencyInfo, core.DependencyInfo] + 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 @@ -445,242 +341,189 @@ class DependencyAnalyzerApp(tk.Frame): 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 and requirements generation complete. File: {self.requirements_file_path}" - ) # Log via root + + 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.""" + """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." - ) # Log via root + logging.debug("Populating tree from requirements.txt as a fallback.") try: with open(self.requirements_file_path, "r", encoding="utf-8") as f: - # ... (fallback logic remains the same) ... - in_external_section = False for line in f: - line_content = line.strip() - if "# --- External Dependencies" in line_content: - in_external_section = True - continue - if "# --- Standard Library Modules Used" in line_content: - in_external_section = False - continue - if ( - in_external_section - and line_content - and not line_content.startswith("#") - and not line_content.startswith("-") - ): - match = re.match(r"([a-zA-Z0-9._-]+)", line_content) + 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),) - ) + self.modules_tree.insert("", tk.END, values=(match.group(1),)) except Exception as e: - self._log_message( - f"Error reading requirements file for tree view: {e}", "WARNING" - ) # User feedback + logging.warning(f"Error reading requirements file for tree view: {e}") - # --- Download Packages --- def _download_packages_threaded(self) -> None: - if not self.requirements_file_path or not self.selected_repository_path: - self._log_message( - "Requirements file or repository path not set.", "WARNING" - ) + if not self.requirements_file_path: + messagebox.showwarning("Warning", "Please generate a requirements.txt file first.") return - if not self.external_deps_info: - self._log_message( - "No external dependencies found in the current analysis to download.", - "INFO", - ) - return - self._run_long_task_threaded( - core.download_packages, - # *args_for_task: - self.selected_repository_path, - self.requirements_file_path, - # keyword-only: + 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: - """Handles the result of the download operation.""" - success, result_message = result - log_level = logging.ERROR if not success else logging.INFO - logging.log( - log_level, f"Download Task Summary: {result_message}" - ) # Log via root + 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) - # --- Create Install Scripts --- def _create_install_scripts_threaded(self) -> None: - if ( - not self.selected_repository_path - or not self.requirements_file_path - or not self.requirements_file_path.exists() - ): - self._log_message( - "Repository not selected or requirements.txt not generated/found.", - "WARNING", - ) + if not self.requirements_file_path: + messagebox.showwarning("Warning", "Please generate a requirements.txt file first.") return - self._run_long_task_threaded( - core.create_install_scripts, - # *args_for_task: - self.selected_repository_path, - self.requirements_file_path, - # keyword-only: + 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: - """Handles the result of the script creation.""" - success, result_message = result - log_level = logging.ERROR if not success else logging.INFO - logging.log( - log_level, f"Script Creation Task Summary: {result_message}" - ) # Log via root + success, message = result + log_level = logging.INFO if success else logging.ERROR + logging.log(log_level, f"Script Creation Task: {message}") - # --- Compare Packages --- def _compare_packages_threaded(self) -> None: - if not self.requirements_file_path or not self.requirements_file_path.exists(): - self._log_message("requirements.txt not found for comparison.", "WARNING") + if not self.requirements_file_path: + messagebox.showwarning("Warning", "Please generate a requirements.txt file first.") return - if ( - not self.external_deps_info - and self.requirements_file_path.stat().st_size == 0 - ): - self._log_message( - "No external dependencies detected and requirements file empty.", "INFO" - ) - self.comparison_tree.delete(*self.comparison_tree.get_children()) - return - self._run_long_task_threaded( self._get_comparison_data, - # No *args_for_task - callback_success=self._compare_packages_callback, # keyword-only + callback_success=self._compare_packages_callback, ) def _get_comparison_data(self) -> List[Dict[str, str]]: - """Helper to run comparison logic in thread.""" + """Helper to run comparison logic in a thread.""" if not self.requirements_file_path: raise ValueError("Requirements file path is not set.") - # core functions log internally - installed_packages = core.get_installed_packages() - return core.compare_requirements_with_installed( + 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.""" + """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, + "", tk.END, values=( - item["package"], - item["required"], - item["installed"], - item["status"], + item["package"], item["required"], + item["installed"], item["status"], ), tags=(item["status"],), ) - logging.info("Package comparison results displayed.") # Log via root + logging.info("Package comparison results displayed.") else: - logging.info("No comparison results to display.") # Log via root + logging.info("No comparison results to display.") + self._update_button_states() - # --- Update System Packages --- def _update_system_packages_threaded(self) -> None: - selected_items_ids = self.comparison_tree.selection() - if not selected_items_ids: - self._log_message( - "No packages selected in the comparison table for update.", "WARNING" - ) + 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: List[str] = [] - for item_id in selected_items_ids: - item_values = self.comparison_tree.item(item_id, "values") - if not item_values: - continue - package_name, _, _, status = item_values - if ( - "Mismatch" in status - or "Not installed" in status - or "Outdated" in status - ): - packages_to_update.append(str(package_name)) - else: - logging.info( - f"Skipping '{package_name}': status '{status}'." - ) # Log via root + 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: - self._log_message("Selected packages do not require an update.", "INFO") + messagebox.showinfo("No Action Needed", "Selected packages do not require an update.") return - if not self.selected_repository_path or not self.requirements_file_path: - self._log_message( - "Repository path or requirements file context not set.", "ERROR" - ) + if not self.selected_repository_path: + logging.error("Repository path context lost. Cannot update packages.") return - if self.winfo_exists(): # Show confirmation dialog - confirmation = messagebox.askyesno( - "Confirm Update", - f"Update/install in current Python environment:\n\n{', '.join(packages_to_update)}\n\n" - "Using versions from project's requirements.txt (if specified).", + 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, ) - if not confirmation: - logging.info("System package update cancelled by user.") - return # Log via root - - self._run_long_task_threaded( - core.update_system_packages, - # *args_for_task: - packages_to_update, - self.selected_repository_path, - # keyword-only: - 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: - """Handles result of system package update.""" - success, result_message = result - log_level = logging.ERROR if not success else logging.INFO - logging.log(log_level, f"Update Task Summary: {result_message}") # Log via root + 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-comparing packages after update attempt..." - ) # Log via root + 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__": - # This block is only for direct testing of gui.py - import sys - root = tk.Tk() - - # --- Setup logging for direct testing --- - log_level = logging.DEBUG # Use DEBUG for testing - log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - kwargs = {"force": True} if sys.version_info >= (3, 8) else {} - logging.basicConfig(level=log_level, format=log_format, stream=sys.stdout, **kwargs) # type: ignore - logging.info("Root logger configured for direct GUI test run.") - # --- End Logging Setup --- - + 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() + app.mainloop() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce52ae4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +# Requirements generated by DependencyAnalyzer for DependencyAnalyzer +# Python Version (analysis env): 3.13.9 + +# --- Standard Library Modules Used (part of Python 3.13.9) --- +# ast (Used in: dependencyanalyzer\core\analyzer.py) +# importlib (Used in: dependencyanalyzer\core\analyzer.py, dependencyanalyzer\core\stdlib_detector.py) +# json (Used in: dependencyanalyzer\core\package_manager.py) +# logging (Used in: dependencyanalyzer\__main__.py, dependencyanalyzer\core\analyzer.py, dependencyanalyzer\core\package_manager.py, ...) +# os (Used in: dependencyanalyzer\__main__.py, dependencyanalyzer\core\analyzer.py, dependencyanalyzer\core\package_manager.py, ...) +# pathlib (Used in: dependencyanalyzer\__main__.py, dependencyanalyzer\core\analyzer.py, dependencyanalyzer\core\package_manager.py, ...) +# re (Used in: dependencyanalyzer\_version.py, dependencyanalyzer\core\package_manager.py, dependencyanalyzer\gui.py) +# shutil (Used in: dependencyanalyzer\core\package_manager.py) +# subprocess (Used in: dependencyanalyzer\core\package_manager.py, dependencyanalyzer\core\project_normalizer.py) +# sys (Used in: dependencyanalyzer\__main__.py, dependencyanalyzer\core\package_manager.py, dependencyanalyzer\core\project_normalizer.py, ...) +# sysconfig (Used in: dependencyanalyzer\core\stdlib_detector.py) +# threading (Used in: dependencyanalyzer\gui.py) +# tkinter (Used in: dependencyanalyzer\__main__.py, dependencyanalyzer\gui.py) +# typing (Used in: dependencyanalyzer\core\analyzer.py, dependencyanalyzer\core\package_manager.py, dependencyanalyzer\core\project_normalizer.py, ...) + +# --- External Dependencies (for pip install) --- +# Found in: dependencyanalyzer\__main__.py +# Version not detected in analysis environment. +core + +# Found in: dependencyanalyzer\__main__.py +# Version not detected in analysis environment. +gui + +# Found in: dependencyanalyzer\core\package_manager.py +packaging==25.0 +