devs/bin/cocomo.py
Mauro Rosero P. 4a372209d9
[DOCS] Actualizada documentación del instalador de Zettlr
- Añadida información sobre características adicionales del instalador
- Detallada la solicitud de permisos de administrador
- Clarificado el uso de interfaz gráfica y línea de comandos
- Documentada la obtención automática de la última versión desde GitHub
- Mencionada la limpieza de archivos de configuración residuales

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-04-04 10:51:40 -05:00

454 lines
No EOL
18 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 -*-
"""
Programa: cocomo.py
Descripción: Calcula el costo de un proyecto de software utilizando el modelo COCOMO
Autor: Mauro Rosero P. <mauro@rosero.one>
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 = BASE_DIR / 'data' / '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)
# Redondear personal promedio al entero próximo (ceil)
avg_staff = ceil(avg_staff)
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)