PAMPAr-Coder / pampar /runtime /scanner.py
lucas-mella's picture
feat: upload PAMPAr-Coder code and documentation
a2d6a0d verified
Raw
History Blame Contribute Delete
10.5 kB
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Lucas Ricardo Mella Chillemi
"""
Scanner — Inspección del entorno al boot.
Escanea el workspace, paquetes instalados, servicios disponibles y
capacidades del sistema. El resultado se usa para generar el AGENTS.md
contextual de cada despliegue.
Principios:
- Solo lectura: ast.parse, no exec/eval
- Sin dependencias externas: usa stdlib pura
- Timeout en detección de servicios
- Resultado como dataclass serializable
"""
import ast
import importlib.metadata
import platform
import shutil
import socket
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
@dataclass
class InfoArchivo:
"""Metadata de un archivo Python del workspace."""
ruta: str
funciones: List[str] = field(default_factory=list)
clases: List[str] = field(default_factory=list)
imports: List[str] = field(default_factory=list)
lineas: int = 0
@dataclass
class InfoSistema:
"""Información del sistema operativo y hardware."""
os: str = ""
os_version: str = ""
python_version: str = ""
arquitectura: str = ""
gpu: Optional[str] = None
vram_mb: Optional[int] = None
ram_gb: Optional[float] = None
@dataclass
class ResultadoScan:
"""Resultado completo del scan del entorno."""
workspace_root: str = ""
archivos: List[InfoArchivo] = field(default_factory=list)
lenguajes: Dict[str, int] = field(default_factory=dict)
paquetes: Dict[str, str] = field(default_factory=dict)
servicios: Dict[str, bool] = field(default_factory=dict)
sistema: InfoSistema = field(default_factory=InfoSistema)
voz: List[str] = field(default_factory=list)
@property
def resumen(self) -> str:
"""Genera resumen legible del scan para inyectar en contexto."""
partes: list[str] = []
partes.append(f"## Entorno detectado\n")
partes.append(f"- **OS**: {self.sistema.os} {self.sistema.os_version}")
partes.append(f"- **Python**: {self.sistema.python_version}")
if self.sistema.gpu:
partes.append(f"- **GPU**: {self.sistema.gpu} ({self.sistema.vram_mb} MB)")
if self.sistema.ram_gb:
partes.append(f"- **RAM**: {self.sistema.ram_gb:.1f} GB")
if self.lenguajes:
langs = ", ".join(f"{k}: {v}" for k, v in sorted(
self.lenguajes.items(), key=lambda x: -x[1],
))
partes.append(f"- **Archivos**: {langs}")
if self.paquetes:
top = list(self.paquetes.items())[:20]
pkgs = ", ".join(f"{n}=={v}" for n, v in top)
partes.append(f"- **Paquetes** ({len(self.paquetes)} total): {pkgs}")
if self.servicios:
activos = [k for k, v in self.servicios.items() if v]
if activos:
partes.append(f"- **Servicios activos**: {', '.join(activos)}")
if self.voz:
partes.append(f"- **Voz**: {', '.join(self.voz)}")
return "\n".join(partes)
class Scanner:
"""
Inspecciona el entorno donde PAMPAr se despliega.
Solo lectura — usa ast.parse (no exec), importlib.metadata,
socket con timeout, platform, shutil.which.
Args:
workspace_root: Directorio raíz del workspace a inspeccionar.
scan_depth: Profundidad máxima de directorios a escanear.
service_timeout: Timeout en segundos para detectar servicios.
"""
EXTENSIONES = {
".py": "Python",
".js": "JavaScript",
".ts": "TypeScript",
".json": "JSON",
".md": "Markdown",
".yaml": "YAML",
".yml": "YAML",
".toml": "TOML",
".sh": "Shell",
".sql": "SQL",
}
SERVICIOS = {
"PostgreSQL": ("127.0.0.1", 5432),
"Redis": ("127.0.0.1", 6379),
"HTTP-8000": ("127.0.0.1", 8000),
"HTTP-3000": ("127.0.0.1", 3000),
}
MOTORES_VOZ = ["espeak", "espeak-ng", "say", "festival"]
# Directorios a ignorar siempre
_IGNORAR = {
"__pycache__", ".git", ".venv", "venv", "node_modules",
".mypy_cache", ".pytest_cache", ".tox", "dist", "build",
"egg-info",
}
def __init__(
self,
workspace_root: str = ".",
scan_depth: int = 5,
service_timeout: float = 0.3,
):
self.root = Path(workspace_root).resolve()
self.scan_depth = scan_depth
self.service_timeout = service_timeout
def scan(self) -> ResultadoScan:
"""
Ejecuta un scan completo del entorno.
Returns:
ResultadoScan con toda la información detectada.
"""
resultado = ResultadoScan(workspace_root=str(self.root))
resultado.sistema = self._scan_sistema()
resultado.archivos, resultado.lenguajes = self._scan_workspace()
resultado.paquetes = self._scan_paquetes()
resultado.servicios = self._scan_servicios()
resultado.voz = self._scan_voz()
return resultado
def _scan_sistema(self) -> InfoSistema:
"""Detecta OS, Python, GPU, RAM."""
info = InfoSistema(
os=platform.system(),
os_version=platform.version(),
python_version=platform.python_version(),
arquitectura=platform.machine(),
)
# GPU detection via torch (si está disponible)
try:
import torch
if torch.cuda.is_available():
info.gpu = torch.cuda.get_device_name(0)
info.vram_mb = torch.cuda.get_device_properties(0).total_memory // (1024 * 1024)
except ImportError:
pass
# RAM detection
try:
import psutil
info.ram_gb = psutil.virtual_memory().total / (1024 ** 3)
except ImportError:
pass
return info
def _scan_workspace(self) -> tuple[list[InfoArchivo], dict[str, int]]:
"""Inspecciona archivos del workspace con ast.parse para .py."""
archivos: list[InfoArchivo] = []
lenguajes: dict[str, int] = {}
if not self.root.is_dir():
return archivos, lenguajes
for path in self._iter_files():
ext = path.suffix.lower()
lang = self.EXTENSIONES.get(ext)
if lang:
lenguajes[lang] = lenguajes.get(lang, 0) + 1
if ext == ".py":
info = self._analizar_python(path)
if info:
archivos.append(info)
return archivos, lenguajes
def _iter_files(self):
"""Itera archivos del workspace respetando profundidad y exclusiones."""
for path in self.root.rglob("*"):
if not path.is_file():
continue
# Verificar profundidad
try:
rel = path.relative_to(self.root)
except ValueError:
continue
if len(rel.parts) > self.scan_depth:
continue
# Ignorar directorios excluidos
if any(
p in self._IGNORAR or p.endswith(".egg-info")
for p in rel.parts
):
continue
yield path
def _analizar_python(self, path: Path) -> Optional[InfoArchivo]:
"""Analiza un archivo .py con ast.parse (no exec)."""
try:
source = path.read_text(encoding="utf-8", errors="ignore")
tree = ast.parse(source, filename=str(path))
except (SyntaxError, UnicodeDecodeError):
return None
rel_path = str(path.relative_to(self.root)).replace("\\", "/")
info = InfoArchivo(ruta=rel_path, lineas=len(source.splitlines()))
for node in ast.iter_child_nodes(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
info.funciones.append(node.name)
elif isinstance(node, ast.ClassDef):
info.clases.append(node.name)
elif isinstance(node, ast.Import):
for alias in node.names:
info.imports.append(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module:
info.imports.append(node.module)
return info
def _scan_paquetes(self) -> dict[str, str]:
"""Detecta paquetes instalados via importlib.metadata."""
paquetes: dict[str, str] = {}
try:
for dist in importlib.metadata.distributions():
name = dist.metadata.get("Name", "")
version = dist.metadata.get("Version", "")
if name:
paquetes[name] = version
except Exception:
pass
return paquetes
def _scan_servicios(self) -> dict[str, bool]:
"""Detecta servicios activos con socket connect + timeout."""
servicios: dict[str, bool] = {}
for nombre, (host, port) in self.SERVICIOS.items():
servicios[nombre] = self._puerto_abierto(host, port)
return servicios
def _puerto_abierto(self, host: str, port: int) -> bool:
"""Verifica si un puerto está abierto (solo localhost)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(self.service_timeout)
return s.connect_ex((host, port)) == 0
except OSError:
return False
def _scan_voz(self) -> list[str]:
"""Detecta motores de voz disponibles en el sistema."""
disponibles: list[str] = []
for motor in self.MOTORES_VOZ:
if shutil.which(motor):
disponibles.append(motor)
# Windows SAPI check
if platform.system() == "Windows":
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Speech\Voices",
)
winreg.CloseKey(key)
disponibles.append("SAPI")
except (ImportError, OSError):
pass
return disponibles