Regex en Python: Compilación, Grupos, Lookarounds y Backtracking

El motor detrás de re

El módulo re implementa un motor NFA (Non-deterministic Finite Automaton): explora múltiples caminos posibles en paralelo para encontrar una coincidencia. Eso explica tanto su flexibilidad como su talón de Aquiles. Cuando escribes un patrón, Python lo compila a una representación interna; si lo haces en un bucle sin re.compile(), lo estás recompilando en cada iteración, aunque el módulo mantiene una caché pequeña (512 entradas por defecto), que se descarta silenciosamente si la superas.

re.compile(patron) devuelve un objeto Pattern que puedes reutilizar indefinidamente. No es una micro-optimización trivial: en parsing de logs donde el mismo patrón se evalúa millones de veces, la diferencia es medible.

Los grupos de captura con () son la unidad de extracción: cada par de paréntesis crea un grupo numerado accesible vía .group(n). Cuando el nombre del grupo importa más que su posición —especialmente cuando el patrón puede cambiar con el tiempo— los grupos nombrados (?P<nombre>...) son la respuesta correcta; el día que añades un grupo antes del que ya tenías, el código que dependía de .group(2) silenciosamente lee datos equivocados.

Los lookarounds son assertions de posición: el motor verifica que algo existe (o no existe) justo antes o después del punto actual, sin avanzar el cursor. Un (?=precio) no consume los caracteres de “precio”; simplemente confirma que están ahí. Esto permite construir patrones que separan el contexto de lo que realmente quieres capturar, sin incluir ese contexto en el match.

re.VERBOSE (alias re.X) hace que el motor ignore espacios en blanco no escapados y cualquier cosa tras # en el patrón. Transforma una línea incomprensible en algo que puedes razonar seis meses después.

El riesgo real está en el catastrophic backtracking: patrones con cuantificadores anidados sobre el mismo conjunto de caracteres —el caso clásico (a+)+— hacen que el motor explore un espacio exponencial de caminos cuando la cadena no coincide. En producción eso se traduce en procesos colgados que nadie entiende por qué consumen el 100% de un core. Si construyes patrones a partir de entrada del usuario, es una superficie de ataque DoS real.

Finalmente: regex es la herramienta incorrecta para HTML o JSON, no por purismo, sino porque esos formatos tienen anidamiento arbitrario que las expresiones regulares clásicas no pueden representar con garantías. Usa html.parser, lxml o json donde corresponde.

import re
import json
import html.parser

# ── 1. Compilar una vez, reutilizar muchas ──────────────────────────────

# Patrón para líneas de log tipo:
#   2024-03-15 14:22:05 ERROR  auth: invalid token for user@example.com
LOG_PATTERN = re.compile(
    r"""
    (?P<date>\d{4}-\d{2}-\d{2})   # fecha ISO
    \s+
    (?P<time>\d{2}:\d{2}:\d{2})   # hora HH:MM:SS
    \s+
    (?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)
    \s+
    (?P<logger>\w+):\s+
    (?P<message>.+)
    """,
    re.VERBOSE,
)

def parse_log_line(line: str) -> dict | None:
    m = LOG_PATTERN.match(line)
    if m is None:
        return None
    # groupdict() aprovecha los nombres declarados en el patrón;
    # si algún día reordenamos los grupos, este código no se rompe.
    return m.groupdict()


sample_lines = [
    "2024-03-15 14:22:05 ERROR  auth: invalid token for user@example.com",
    "2024-03-15 14:22:06 INFO   db: connection pool ready",
    "ESTA LÍNEA ES INVÁLIDA",
]

for line in sample_lines:
    parsed = parse_log_line(line)
    if parsed:
        print(f"[{parsed['level']}] {parsed['logger']} → {parsed['message']}")


# ── 2. Lookahead y lookbehind ───────────────────────────────────────────

prices_text = "apple: $4.99, banana: €1.20, mango: $2.50"

# Queremos solo los números que van precedidos por '$' (no '€').
# Lookbehind (?<=\$) verifica el contexto sin incluirlo en el match.
usd_values = re.findall(r"(?<=\$)\d+\.\d{2}", prices_text)
print("USD:", usd_values)  # ['4.99', '2.50']

# Lookahead (?=\s*kg): extraer números seguidos de "kg" sin capturar la unidad.
weights_text = "paquete A: 12.5 kg, paquete B: 0.8 kg, paquete C: 200 g"
kg_values = re.findall(r"\d+\.?\d*(?=\s*kg)", weights_text)
print("KG:", kg_values)  # ['12.5', '0.8']


# ── 3. Catastrophic backtracking: demo controlada ───────────────────────

import time

# El patrón (x+x+)+y es el ejemplo canónico de backtracking exponencial.
# Con una cadena de 'x' sin 'y' al final, el motor explora 2^n caminos.
DANGEROUS_PATTERN = re.compile(r"(x+x+)+y")

test_string = "x" * 25  # 25 caracteres, sin 'y' → el motor no puede coincidir

start = time.perf_counter()
result = DANGEROUS_PATTERN.search(test_string)
elapsed = time.perf_counter() - start

# Con 25 'x' puede tardar varios segundos; con 35+ tardaría minutos.
print(f"Match: {result}, tiempo: {elapsed:.3f}s")

# Versión segura: eliminar la ambigüedad en el cuantificador.
# (x+) sin anidamiento equivalente; el motor no tiene caminos alternativos.
SAFE_PATTERN = re.compile(r"x+y")

start = time.perf_counter()
result = SAFE_PATTERN.search(test_string)
elapsed = time.perf_counter() - start
print(f"Match (safe): {result}, tiempo: {elapsed:.6f}s")


# ── 4. Cuándo NO usar regex: JSON y HTML ───────────────────────────────

# ❌ Extraer valores JSON con regex es frágil ante espacios, saltos de línea,
#    comillas escapadas o anidamiento. json.loads es la única respuesta correcta.
raw_json = '{"user": "alice", "score": 42, "tags": ["python", "regex"]}'
data = json.loads(raw_json)
print("Score:", data["score"])  # 42, siempre correcto

# ❌ Parsear HTML con regex falla ante atributos reordenados, auto-closing tags,
#    entidades HTML y cualquier variación válida del estándar.
class TitleExtractor(html.parser.HTMLParser):
    def __init__(self):
        super().__init__()
        self._in_title = False
        self.title: str = ""

    def handle_starttag(self, tag, attrs):
        if tag == "title":
            self._in_title = True

    def handle_data(self, data):
        if self._in_title:
            self.title += data

    def handle_endtag(self, tag):
        if tag == "title":
            self._in_title = False

parser = TitleExtractor()
parser.feed("<html><head><title>Guía de Regex</title></head></html>")
print("Título:", parser.title)

Desglose del código

LOG_PATTERN muestra tres decisiones juntas: re.compile() porque este patrón se reutiliza en cada línea de un archivo potencialmente enorme; re.VERBOSE porque sin él sería una cadena de 80 caracteres que nadie puede auditar; y grupos nombrados porque groupdict() produce un diccionario cuyos campos tienen semántica propia, no posiciones frágiles. Si mañana añades un grupo para la zona horaria, el código consumidor no se rompe.

El bloque de lookarounds ilustra la diferencia entre contexto y contenido. (?<=\$)\d+\.\d{2} dice: “quiero dígitos que estén después de un signo de dólar, pero no quiero el signo en el resultado”. Sin lookbehind tendrías que capturar el signo y luego descartarlo, o usar un grupo y acceder a .group(1) en lugar de la lista plana que devuelve findall. Los lookarounds mantienen el código de extracción limpio.

La demo de backtracking catastrófico es deliberadamente corta: 25 caracteres son suficientes para ver latencia perceptible. El patrón (x+x+)+y es problemático porque x+x+ y (...)+ crean ambigüedad: hay múltiples formas de repartir los caracteres x entre los cuantificadores, y el motor NFA las explora todas antes de rendirse. La solución no siempre es “simplifica el patrón”; a veces es usar el módulo regex de terceros que soporta possessive quantifiers (x++) y atomic groups ((?>...)) que eliminan backtracking donde sabes que no hay alternativas válidas.

El módulo regex (PyPI: regex) merece mención explícita cuando necesitas: soporte completo de Unicode Level 1 con propiedades como \p{Letter}, grupos repetidos con CAPTURES que recuerdan cada iteración de un grupo, o los cuantificadores posesivos ya mencionados. Para la mayoría de casos re es suficiente, pero en procesamiento de texto multilingüe \w de re tiene comportamiento inconsistente según la plataforma y el flag re.UNICODE.

El cierre con JSON y HTML no es dogma: es pragmatismo. Un parser dedicado sabe qué tokens son válidos según la gramática del formato; un regex no tiene esa información y falla silenciosamente ante variaciones completamente legales.

Errores que debes conocer

Error: usar re.match() cuando necesitas re.search(), creyendo que match busca en todo el string.

text = "Error: archivo no encontrado"

# ❌ Wrong — match solo ancla al inicio; no encuentra "archivo"
found = re.match(r"\barchivo\b", text)
print(found)  # None

# ✅ Right — search recorre todo el string
found = re.search(r"\barchivo\b", text)
print(found.group())  # 'archivo'

re.match() es equivalente a re.search() con ^ implícito al inicio; si tu patrón no debe estar necesariamente al principio del string, usa search.


Error: construir grupos con () cuando solo necesitas agrupar para el cuantificador, inflando findall con tuplas inesperadas.

text = "ha ha ha jaja"

# ❌ Wrong — findall devuelve el contenido del grupo capturador, no el match completo
result = re.findall(r"(ha)+", text)
print(result)  # ['ha', 'ha', 'ha'] — pierdes la repetición

# ✅ Right — grupo no capturador (?:...) agrupa sin crear un grupo de captura
result = re.findall(r"(?:ha)+", text)
print(result)  # ['ha', 'ha', 'ha'] con el match real; o ajusta según necesidad

(?:...) agrupa para aplicar cuantificadores o alternancia sin contaminar los grupos numerados ni el comportamiento de findall.


Error: aplicar regex a HTML/JSON en lugar de un parser, obteniendo resultados que pasan el 95% de los tests pero fallan en producción ante variaciones válidas del formato.

html = '<a href="https://example.com" class="link">texto</a>'

# ❌ Wrong — falla si los atributos están en otro orden o hay atributos extra
url = re.search(r'<a href="([^"]+)">', html)

# ✅ Right — usa un parser que entiende la gramática
from html.parser import HTMLParser

class LinkExtractor(HTMLParser):
    def handle_starttag(self, tag, attrs):
        if tag == "a":
            attrs_dict = dict(attrs)
            print(attrs_dict.get("href"))

LinkExtractor().feed(html)

Un parser construye un árbol semántico; regex ve bytes. Para formatos con gramática definida, el parser siempre gana en robustez.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio