aggiunto normalizzazione del progetto python

This commit is contained in:
VALLONGOL 2025-11-10 14:18:35 +01:00
parent b3b8736e36
commit e8485703b3
9 changed files with 1662 additions and 1612 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

View File

@ -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

View File

@ -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}")

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

31
requirements.txt Normal file
View File

@ -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