devs/bin/cocomo.py
Mauro Rosero P. 08dfaef620
[IMPROVED] Mostrar tipo de programador usado para el cálculo de tarifas
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-12 14:12:24 -05:00

396 lines
No EOL
16 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
COCOMO Calculator
Este script calcula el costo de un proyecto de software utilizando el modelo COCOMO,
basado en el conteo de líneas de código en un directorio dado.
Licencia: AGPL-3.0
Modified: 2025-03-12
"""
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_fullstack_rate():
"""
Obtiene la tarifa por hora de un desarrollador fullstack desde el archivo de configuración.
Si el archivo no existe, devuelve un valor predeterminado de 45.00 USD/hora.
Returns:
Float: Tarifa por hora en USD
"""
rate_file = RATES_DIR / "fullstack.rate"
# Comprobar si existe el archivo
if not rate_file.exists():
logger.warning(f"No se encontró el archivo de tarifa: {rate_file}")
logger.warning("Usando tarifa predeterminada de 45.00 USD/hora")
return 45.00
# 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("Usando tarifa predeterminada de 45.00 USD/hora")
return 45.00
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__":
# 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('--cost', type=float,
help='Costo por persona-mes en USD (por defecto: tarifa fullstack × 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 costo por hora desde fullstack.rate y convertir a costo por persona-mes
programmer_type = 'fullstack' # Por defecto usamos la tarifa de fullstack
if args.cost is None:
# Obtener la tarifa por hora y multiplicar por las horas mensuales
hourly_rate = get_fullstack_rate()
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 = 'personalizado' # Si se especifica manualmente
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)