#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Programa: cocomo.py Descripción: Calcula el costo de un proyecto de software utilizando el modelo COCOMO Autor: Mauro Rosero P. Creación: 2025-03-12 Actualización: 2025-03-12 Versión: 1.0 Licencia: AGPL """ import os import sys import argparse import logging import fnmatch from pathlib import Path from math import ceil # Configuración de logging console_handler = logging.StreamHandler() console_handler.setLevel(logging.WARNING) console_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) # Configuración global del logger logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[]) logger = logging.getLogger('cocomo') logger.addHandler(console_handler) # Variable para controlar si se muestra el resultado en la consola SHOW_RESULTS = True # Directorio base del proyecto BASE_DIR = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) CONFIG_DIR = BASE_DIR / 'bin' / 'config' RATES_DIR = CONFIG_DIR / 'rates' # Ubicación para archivos de tarifas # Constantes para el modelo COCOMO básico COCOMO_MODELS = { # [a, b, c, d] - Coeficientes para Esfuerzo = a * (KLOC^b) y Tiempo = c * (Esfuerzo^d) 'organic': [2.4, 1.05, 2.5, 0.38], # Proyectos simples, pequeños equipos con experiencia 'semi-detached': [3.0, 1.12, 2.5, 0.35], # Proyectos intermedios 'embedded': [3.6, 1.20, 2.5, 0.32] # Proyectos complejos } # Número de horas laborables en un mes (22 días x 8 horas) HOURS_PER_MONTH = 176 # Extensiones de archivo a considerar por defecto DEFAULT_EXTENSIONS = [ '.py', '.java', '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.js', '.ts', '.html', '.css', '.php', '.rb', '.go', '.rs', '.swift', '.kt', '.scala', '.sh', '.bash', '.pl', '.pm', '.lua', '.sql' ] # Archivos a ignorar por defecto DEFAULT_IGNORE_PATTERNS = [ '*.min.js', '*.min.css', '**/node_modules/**', '**/venv/**', '**/.git/**', '**/__pycache__/**', '**/build/**', '**/dist/**', '**/bin/config/rates/**', '**/bin/msg/**' ] def count_lines_of_code(project_path, extensions=None, ignore_patterns=None): """ Cuenta las líneas de código no vacías en los archivos con las extensiones especificadas. Excluye comentarios en archivos Python. Args: project_path: Ruta del directorio del proyecto extensions: Lista de extensiones de archivo a considerar ignore_patterns: Lista de patrones de archivo a ignorar Returns: Número total de líneas de código """ if extensions is None: extensions = DEFAULT_EXTENSIONS if ignore_patterns is None: ignore_patterns = DEFAULT_IGNORE_PATTERNS total_lines = 0 files_counted = 0 skipped_files = 0 logger.info(f"Contando líneas de código en: {project_path}") logger.info(f"Extensiones: {extensions}") for root, _, files in os.walk(project_path): for file in files: file_path = os.path.join(root, file) rel_path = os.path.relpath(file_path, project_path) # Verificar si el archivo debe ser ignorado should_ignore = False for pattern in ignore_patterns: if fnmatch.fnmatch(rel_path, pattern): should_ignore = True break if should_ignore: logger.debug(f"Ignorando archivo: {rel_path}") skipped_files += 1 continue # Verificar si la extensión está en la lista file_extension = os.path.splitext(file)[1].lower() if file_extension not in extensions: logger.debug(f"Extensión no considerada: {rel_path}") skipped_files += 1 continue try: with open(file_path, 'r', encoding='utf-8') as f: lines = f.readlines() # Contar líneas no vacías (excluyendo comentarios en Python) if file_extension == '.py': non_empty_lines = 0 in_multiline_comment = False for line in lines: line = line.strip() # Saltar líneas vacías if not line: continue # Manejar comentarios multilinea if '"""' in line or "'''" in line: # Si ya estamos en un comentario multilinea, verificamos si termina if in_multiline_comment: if line.endswith('"""') or line.endswith("'''"): in_multiline_comment = False continue # Si encontramos el inicio de un comentario multilinea elif line.startswith('"""') or line.startswith("'''"): # Verificar si el comentario comienza y termina en la misma línea if line.endswith('"""') or line.endswith("'''"): if line.count('"""') == 2 or line.count("'''") == 2: continue else: in_multiline_comment = True continue # Saltar si estamos en un comentario multilinea if in_multiline_comment: continue # Saltar comentarios de una línea if line.startswith('#'): continue # Contar línea de código válida non_empty_lines += 1 total_lines += non_empty_lines else: # Para otros lenguajes, simplemente contamos líneas no vacías non_empty_lines = sum(1 for line in lines if line.strip()) total_lines += non_empty_lines files_counted += 1 if files_counted % 100 == 0: logger.info(f"Procesados {files_counted} archivos, {total_lines} líneas contadas hasta ahora.") except Exception as e: logger.warning(f"Error al procesar archivo {file_path}: {e}") skipped_files += 1 logger.info(f"Conteo completo. Procesados {files_counted} archivos, ignorados {skipped_files}.") return total_lines def estimate_cocomo(loc, model_type='organic', cost_per_pm=5000, programmer_type='fullstack'): """ Calcula las estimaciones COCOMO basadas en las líneas de código. Args: loc: Líneas de código model_type: Tipo de modelo COCOMO ('organic', 'semi-detached', 'embedded') cost_per_pm: Costo por persona-mes en USD programmer_type: Tipo de programador usado para el cálculo de costo Returns: Dictionary con los resultados del cálculo """ # Convertir LOC a KLOC (miles de líneas de código) kloc = loc / 1000 # Obtener coeficientes del modelo a, b, c, d = COCOMO_MODELS[model_type] # Calcular esfuerzo en persona-mes effort_pm = a * (kloc ** b) # Calcular tiempo de desarrollo en meses dev_time = c * (effort_pm ** d) # Calcular personas requeridas (promedio) avg_staff = effort_pm / dev_time # Calcular costo total total_cost = effort_pm * cost_per_pm # Calcular costo por hora (dividir el costo por persona-mes entre las horas laborables) hourly_rate = cost_per_pm / HOURS_PER_MONTH # Redondear valores para presentación effort_pm = round(effort_pm, 2) dev_time = round(dev_time, 2) avg_staff = round(avg_staff, 2) hourly_rate = round(hourly_rate, 2) total_cost = round(total_cost, 2) # Preparar resultados results = { 'loc': loc, 'kloc': round(kloc, 2), 'model': model_type, 'effort_pm': effort_pm, 'dev_time': dev_time, 'avg_staff': avg_staff, 'cost_per_pm': cost_per_pm, 'hourly_rate': hourly_rate, 'programmer_type': programmer_type, 'total_cost': total_cost } return results def show_result(message): """ Función para mostrar resultados en la consola. Solo muestra mensajes si SHOW_RESULTS es True. """ if SHOW_RESULTS: print(message) def determine_cocomo_model(loc): """ Determina automáticamente el modelo COCOMO basado en el tamaño del proyecto. Args: loc: Líneas de código Returns: Tipo de modelo COCOMO ('organic', 'semi-detached', 'embedded') """ kloc = loc / 1000 if kloc < 50: return 'organic' # Proyectos pequeños (menos de 50K líneas) elif kloc < 300: return 'semi-detached' # Proyectos medianos (entre 50K y 300K líneas) else: return 'embedded' # Proyectos grandes (más de 300K líneas) def get_available_programmer_types(): """ Obtiene una lista de los tipos de programadores disponibles basados en los archivos .rate existentes en el directorio de tarifas. Returns: List: Lista de tipos de programadores disponibles """ if not RATES_DIR.exists(): logger.warning(f"El directorio de tarifas no existe: {RATES_DIR}") return ['fullstack'] # Por defecto solo fullstack si no hay directorio try: # Buscar archivos .rate en el directorio rate_files = list(RATES_DIR.glob('*.rate')) # Extraer nombres de programadores de los nombres de archivo programmer_types = [file.stem for file in rate_files] if not programmer_types: logger.warning("No se encontraron archivos de tarifas. Usando solo 'fullstack' por defecto.") return ['fullstack'] return sorted(programmer_types) except Exception as e: logger.error(f"Error al buscar tipos de programadores disponibles: {e}") return ['fullstack'] # En caso de error, devolver tipo por defecto def get_programmer_rate(programmer_type='fullstack'): """ Obtiene la tarifa por hora de un tipo específico de programador desde el archivo de configuración. Si el archivo no existe, devuelve un valor predeterminado según el tipo. Args: programmer_type: Tipo de programador (por defecto: 'fullstack') Returns: Float: Tarifa por hora en USD """ rate_file = RATES_DIR / f"{programmer_type}.rate" # Valores predeterminados por tipo de programador default_rates = { 'fullstack': 45.00, 'frontend': 40.00, 'backend': 42.00, 'devops': 50.00, 'python': 35.00, 'java': 40.00, 'php': 30.00, 'mobile': 45.00, 'data': 55.00, 'ml': 65.00, 'cloud': 60.00, 'bash': 20.00 } default_rate = default_rates.get(programmer_type, 40.00) # Comprobar si existe el archivo if not rate_file.exists(): logger.warning(f"No se encontró el archivo de tarifa: {rate_file}") logger.warning(f"Usando tarifa predeterminada de {default_rate:.2f} USD/hora para {programmer_type}") return default_rate # Leer la tarifa del archivo try: with open(rate_file, 'r', encoding='utf-8') as f: rate = float(f.read().strip()) logger.info(f"Tarifa leída desde {rate_file}: {rate:.2f} USD/hora") return rate except (FileNotFoundError, ValueError, IOError) as e: logger.warning(f"Error al leer el archivo de tarifa {rate_file}: {e}") logger.warning(f"Usando tarifa predeterminada de {default_rate:.2f} USD/hora para {programmer_type}") return default_rate def print_results(results): """ Imprime los resultados del cálculo COCOMO de manera formateada. Args: results: Dictionary con los resultados del cálculo COCOMO """ print("\n========== RESULTADOS DEL ANÁLISIS COCOMO ==========") print(f"Proyecto: {results['project_path']}") print(f"Líneas de código: {results['loc']} ({results['kloc']} KLOC)") print(f"Modelo COCOMO: {results['model'].upper()}") print("\n--- Estimaciones ---") print(f"Esfuerzo: {results['effort_pm']} persona-meses") print(f"Tiempo de desarrollo: {results['dev_time']} meses") print(f"Personal promedio: {results['avg_staff']} personas") print("\n--- Costos ---") print(f"Tipo de programador: {results['programmer_type']}") print(f"Costo por hora: ${results['hourly_rate']}") print(f"Costo por persona-mes: ${results['cost_per_pm']} ({HOURS_PER_MONTH} horas)") print(f"Costo total estimado: ${results['total_cost']:,.2f}") print("=====================================================\n") if __name__ == "__main__": # Obtener los tipos de programadores disponibles para el parámetro --type available_types = get_available_programmer_types() available_types_str = ', '.join(available_types) # Parámetros de línea de comandos parser = argparse.ArgumentParser(description='Calculadora COCOMO para estimar costos de proyectos de software') parser.add_argument('--project', required=True, help='Ruta del directorio del proyecto a analizar') parser.add_argument('--model', choices=['organic', 'semi-detached', 'embedded', 'auto'], default='auto', help='Tipo de modelo COCOMO a utilizar (por defecto: auto)') parser.add_argument('--type', choices=available_types, default='fullstack', help=f'Tipo de programador para calcular costos (por defecto: fullstack). Disponibles: {available_types_str}') parser.add_argument('--cost', type=float, help='Costo por persona-mes en USD (por defecto: tarifa del tipo de programador × 176)') parser.add_argument('--ignore', action='append', help='Patrones adicionales de archivos a ignorar') parser.add_argument('--ext', action='append', help='Extensiones adicionales de archivo a considerar') parser.add_argument('-q', '--quiet', action='store_true', help='No mostrar resultados detallados') parser.add_argument('-v', '--verbose', action='store_true', help='Mostrar información detallada del proceso') args = parser.parse_args() # Configurar nivel de log según parámetros if args.verbose: console_handler.setLevel(logging.INFO) # Configurar si se muestran resultados SHOW_RESULTS = not args.quiet # Verificar que el directorio del proyecto existe project_path = Path(args.project) if not project_path.exists() or not project_path.is_dir(): logger.error(f"El directorio del proyecto no existe: {args.project}") print(f"ERROR: El directorio del proyecto no existe: {args.project}") sys.exit(1) # Verificar que el directorio de tarifas existe if not RATES_DIR.exists(): logger.warning(f"El directorio de tarifas no existe: {RATES_DIR}") logger.warning("Se creará el directorio y se usarán valores predeterminados.") try: os.makedirs(RATES_DIR, exist_ok=True) except Exception as e: logger.error(f"Error al crear el directorio de tarifas: {e}") # Obtener tipo de programador y su tarifa programmer_type = args.type # Usar el tipo especificado por el usuario # Obtener costo por hora desde el archivo .rate y convertir a costo por persona-mes if args.cost is None: # Obtener la tarifa por hora y multiplicar por las horas mensuales hourly_rate = get_programmer_rate(programmer_type) cost_per_pm = hourly_rate * HOURS_PER_MONTH logger.info(f"Costo por hora ({programmer_type}): ${hourly_rate:.2f} × {HOURS_PER_MONTH} horas = ${cost_per_pm:.2f} por persona-mes") else: cost_per_pm = args.cost programmer_type = f"{programmer_type} (personalizado)" # Indicar que es un valor personalizado hourly_rate = cost_per_pm / HOURS_PER_MONTH logger.info(f"Usando costo por persona-mes especificado: ${cost_per_pm:.2f} (${hourly_rate:.2f}/hora)") # Preparar extensiones y patrones de ignorar extensions = DEFAULT_EXTENSIONS.copy() if args.ext: extensions.extend([ext if ext.startswith('.') else f'.{ext}' for ext in args.ext]) ignore_patterns = DEFAULT_IGNORE_PATTERNS.copy() if args.ignore: ignore_patterns.extend(args.ignore) try: # Contar líneas de código loc = count_lines_of_code(project_path, extensions, ignore_patterns) # Determinar el modelo COCOMO a utilizar if args.model == 'auto': model_type = determine_cocomo_model(loc) logger.info(f"Modelo seleccionado automáticamente: {model_type}") else: model_type = args.model # Calcular estimaciones COCOMO results = estimate_cocomo(loc, model_type, cost_per_pm, programmer_type) # Añadir ruta del proyecto para la presentación results['project_path'] = str(project_path) # Mostrar resultados print_results(results) except Exception as e: logger.error(f"Error durante el cálculo COCOMO: {e}") print(f"ERROR: {e}") sys.exit(1)