209 lines
7.5 KiB
Python
209 lines
7.5 KiB
Python
# 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 |