El módulo ast en Python: analizar, recorrer y modificar código Python como árbol de sintaxis

El módulo ast (Abstract Syntax Tree) de Python permite analizar, recorrer y transformar código fuente Python como un árbol de objetos. Es la herramienta que usan linters, formateadores, generadores de documentación y transpiladores. Con él puedes escribir tus propias reglas de calidad de código, detectar patrones peligrosos o transformar código automáticamente.

ast.parse(): convertir código en árbol

import ast

codigo = """
x = 10
y = x * 2 + 5
print(f"Resultado: {y}")
"""

arbol = ast.parse(codigo)
print(type(arbol))   # <class 'ast.Module'>
print(ast.dump(arbol, indent=2))

El resultado de ast.dump muestra la estructura completa del árbol: nodos, campos y valores. Es la mejor forma de entender qué tipo de nodo corresponde a cada construcción Python.

Explorar el árbol con ast.walk

import ast

codigo = """
def calcular(a, b):
    resultado = a + b * 2
    return resultado

class Punto:
    def __init__(self, x, y):
        self.x = x
        self.y = y
"""

arbol = ast.parse(codigo)

# Encontrar todas las definiciones de función
funciones = [nodo.name for nodo in ast.walk(arbol) if isinstance(nodo, ast.FunctionDef)]
print(f"Funciones: {funciones}")   # ['calcular', '__init__']

# Encontrar todas las clases
clases = [nodo.name for nodo in ast.walk(arbol) if isinstance(nodo, ast.ClassDef)]
print(f"Clases: {clases}")   # ['Punto']

# Encontrar todas las llamadas a funciones
llamadas = [
    nodo.func.id
    for nodo in ast.walk(arbol)
    if isinstance(nodo, ast.Call) and isinstance(nodo.func, ast.Name)
]
print(f"Llamadas: {llamadas}")

NodeVisitor: recorrer el árbol con patrón Visitor

import ast
from collections import defaultdict


class AnalizadorImports(ast.NodeVisitor):
    """Recoge todos los imports del código fuente."""

    def __init__(self):
        self.imports: list[str] = []
        self.from_imports: dict[str, list[str]] = defaultdict(list)

    def visit_Import(self, nodo: ast.Import):
        for alias in nodo.names:
            self.imports.append(alias.name)
        self.generic_visit(nodo)   # visitar hijos

    def visit_ImportFrom(self, nodo: ast.ImportFrom):
        modulo = nodo.module or ''
        for alias in nodo.names:
            self.from_imports[modulo].append(alias.name)
        self.generic_visit(nodo)


codigo = """
import os
import sys
from pathlib import Path
from collections import defaultdict, OrderedDict
from typing import Optional, List
"""

arbol = ast.parse(codigo)
analizador = AnalizadorImports()
analizador.visit(arbol)

print(f"Imports directos: {analizador.imports}")
for modulo, nombres in analizador.from_imports.items():
    print(f"  from {modulo} import {', '.join(nombres)}")

Linter personalizado: detectar patrones problemáticos

import ast
from dataclasses import dataclass, field


@dataclass
class Aviso:
    linea: int
    columna: int
    codigo: str
    mensaje: str

    def __str__(self) -> str:
        return f"L{self.linea}:C{self.columna} [{self.codigo}] {self.mensaje}"


class LinterSimple(ast.NodeVisitor):
    """Detecta malas prácticas comunes."""

    def __init__(self):
        self.avisos: list[Aviso] = []

    def _avisar(self, nodo: ast.AST, codigo: str, mensaje: str):
        self.avisos.append(Aviso(nodo.lineno, nodo.col_offset, codigo, mensaje))

    def visit_Compare(self, nodo: ast.Compare):
        """Detecta comparaciones con True/False/None sin is/is not."""
        for op, comparador in zip(nodo.ops, nodo.comparators):
            if isinstance(comparador, ast.Constant) and comparador.value is None:
                if isinstance(op, (ast.Eq, ast.NotEq)):
                    self._avisar(nodo, "E001", "Usa 'is None' o 'is not None' en lugar de == None")
            if isinstance(comparador, ast.Constant) and isinstance(comparador.value, bool):
                if isinstance(op, (ast.Eq, ast.NotEq)):
                    self._avisar(nodo, "E002", "Usa 'is True'/'is False' en lugar de == True/False")
        self.generic_visit(nodo)

    def visit_FunctionDef(self, nodo: ast.FunctionDef):
        """Detecta funciones sin docstring."""
        tiene_docstring = (
            nodo.body and
            isinstance(nodo.body[0], ast.Expr) and
            isinstance(nodo.body[0].value, ast.Constant) and
            isinstance(nodo.body[0].value.value, str)
        )
        if not tiene_docstring and not nodo.name.startswith('_'):
            self._avisar(nodo, "E003", f"Función '{nodo.name}' sin docstring")
        self.generic_visit(nodo)

    def visit_Raise(self, nodo: ast.Raise):
        """Detecta 'raise Exception' genérico."""
        if isinstance(nodo.exc, ast.Call):
            if isinstance(nodo.exc.func, ast.Name) and nodo.exc.func.id == 'Exception':
                self._avisar(nodo, "E004", "Usa excepciones específicas en lugar de Exception genérico")
        self.generic_visit(nodo)


codigo_a_analizar = """
def calcular(x, y):
    if x == None:
        raise Exception("x no puede ser None")
    if y == True:
        return x
    return x + y
"""

arbol = ast.parse(codigo_a_analizar)
linter = LinterSimple()
linter.visit(arbol)

for aviso in linter.avisos:
    print(aviso)

NodeTransformer: modificar el árbol

import ast


class TransformadorPrint(ast.NodeTransformer):
    """Reemplaza print(x) por logging.info(x)."""

    def visit_Call(self, nodo: ast.Call):
        self.generic_visit(nodo)   # transformar hijos primero
        if isinstance(nodo.func, ast.Name) and nodo.func.id == 'print':
            nodo.func = ast.Attribute(
                value=ast.Name(id='logging', ctx=ast.Load()),
                attr='info',
                ctx=ast.Load()
            )
        return nodo


codigo_original = """
print("Iniciando...")
resultado = 42
print(f"Resultado: {resultado}")
"""

arbol = ast.parse(codigo_original)
arbol_modificado = TransformadorPrint().visit(arbol)
ast.fix_missing_locations(arbol_modificado)

# Compilar y ejecutar el árbol modificado
import logging
logging.basicConfig(level=logging.INFO)
exec(compile(arbol_modificado, '<string>', 'exec'))

ast.literal_eval: evaluar expresiones de forma segura

import ast

# PELIGROSO: eval() puede ejecutar cualquier código
# resultado = eval("[1, 2, 3]")

# SEGURO: literal_eval solo acepta literales Python
datos = ast.literal_eval("[1, 2, {'clave': 'valor'}, (3, 4)]")
print(datos)          # [1, 2, {'clave': 'valor'}, (3, 4)]
print(type(datos))    # <class 'list'>

# Valores aceptados: str, bytes, int, float, bool, None, list, tuple, dict, set
configuracion = ast.literal_eval("{'host': 'localhost', 'puerto': 5432, 'debug': True}")
print(configuracion['host'])   # localhost

# Rechaza código arbitrario:
try:
    ast.literal_eval("__import__('os').system('rm -rf /')")
except ValueError as e:
    print(f"Rechazado correctamente: {e}")

El módulo ast es la puerta de entrada al metaprogramación estática en Python. Herramientas como black, flake8, pylint, mypy y bandit se construyen sobre él. Dominarlo te permite escribir herramientas propias de análisis de código adaptadas exactamente a las convenciones de tu proyecto.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP