Ambos métodos interceptan el acceso a atributos, pero operan en momentos completamente distintos del proceso de resolución, y confundirlos produce bugs que son casi imposibles de diagnosticar por la forma en que fallan.
__getattribute__ es la puerta de entrada universal: Python lo invoca en absolutamente todo acceso a atributo, obj.x, obj.method, obj.__class__, sin excepción. Es el mecanismo primario de resolución. __getattr__, en cambio, es el fallback: Python solo lo llama cuando la maquinaria normal —__getattribute__ incluida— no encontró el atributo y lanzó AttributeError. Es el mecanismo secundario.
El diseño tiene sentido si lo ves como un pipeline: __getattribute__ siempre se ejecuta primero, busca en __dict__ de la instancia, en __dict__ de la clase, sube por el MRO y, si no encuentra nada, lanza AttributeError. Solo cuando ese error llega sin capturar, Python lo intercepta automáticamente y llama a __getattr__. La separación te permite intervenir en el último momento posible (__getattr__) o en el primero de todos (__getattribute__), con costes muy distintos.
El peligro concreto de sobreescribir __getattribute__ es la recursión infinita. Si dentro de tu implementación escribes self.x, Python vuelve a invocar tu __getattribute__ para resolver ese self.x, que a su vez intenta resolver self.x, y así hasta RecursionError. La salida es siempre super().__getattribute__(name), que salta directamente a la implementación de object y usa la cadena de resolución normal sin volver a pasar por tu override.
__getattr__ es el adecuado para la mayoría de los casos reales: proxies transparentes, clientes de APIs donde los métodos se construyen dinámicamente, ORMs con carga lazy. No tiene el problema de recursión porque solo actúa cuando el atributo genuinamente no existe, y si lo necesitas puedes delegar sin miedo.
from __future__ import annotations
import time
from typing import Any
# ── Caso 1: __getattr__ para un proxy de API remota ──────────────────────────
class APIClient:
"""Genera endpoints dinámicamente: client.users, client.orders, etc."""
def __init__(self, base_url: str) -> None:
# Usamos object.__setattr__ para evitar recursión si sobreescribiéramos
# __setattr__ también, pero aquí es solo buena costumbre defensiva.
self._base_url = base_url
self._cache: dict[str, "Endpoint"] = {}
def __getattr__(self, name: str) -> "Endpoint":
# Solo llega aquí si `name` no existe en __dict__ ni en la clase.
# `_base_url` y `_cache` nunca pasan por aquí: ya están en __dict__.
if name not in self._cache:
self._cache[name] = Endpoint(f"{self._base_url}/{name}")
return self._cache[name]
class Endpoint:
def __init__(self, url: str) -> None:
self.url = url
def get(self, resource_id: int) -> str:
return f"GET {self.url}/{resource_id}"
# ── Caso 2: __getattribute__ para auditoría de accesos ───────────────────────
class Audited:
"""Registra cada acceso a atributo, incluyendo los que existen."""
def __init__(self, **kwargs: Any) -> None:
for key, value in kwargs.items():
# object.__setattr__ porque si sobreescribiéramos __setattr__,
# self.x = v aquí entraría en el mismo problema de recursión.
object.__setattr__(self, key, value)
object.__setattr__(self, "_access_log", [])
def __getattribute__(self, name: str) -> Any:
# Capturamos el log ANTES de resolver, usando super() para no recursar.
log: list = super().__getattribute__("_access_log")
if not name.startswith("_"): # no auditamos atributos privados
log.append((name, time.monotonic()))
# Delegamos a la implementación estándar: busca en __dict__, MRO, etc.
return super().__getattribute__(name)
def access_summary(self) -> dict[str, int]:
log = object.__getattribute__(self, "_access_log")
summary: dict[str, int] = {}
for name, _ in log:
summary[name] = summary.get(name, 0) + 1
return summary
# ── Caso 3: __getattr__ para ORM con carga lazy ───────────────────────────────
class LazyRecord:
"""Simula un campo de ORM que hace la query solo cuando se accede."""
def __init__(self, record_id: int) -> None:
self._id = record_id
self._loaded = False
self._data: dict[str, Any] = {}
def _load(self) -> None:
"""Simula una consulta a base de datos."""
print(f" [SQL] SELECT * FROM records WHERE id = {self._id}")
self._data = {"name": "Alice", "score": 42, "active": True}
self._loaded = True
def __getattr__(self, name: str) -> Any:
# Solo llegamos aquí si `name` no está en __dict__.
# `_id`, `_loaded`, `_data` están en __dict__ → nunca pasan por aquí.
if not self._loaded:
self._load()
try:
return self._data[name]
except KeyError:
raise AttributeError(
f"{type(self).__name__!r} no tiene el campo {name!r}"
) from None
# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("=== APIClient ===")
client = APIClient("https://api.example.com/v1")
print(client.users.get(7))
print(client.orders.get(42))
print(client.users.get(9)) # viene del caché
print("\n=== Audited ===")
record = Audited(name="Bob", age=30, city="Madrid")
_ = record.name
_ = record.age
_ = record.name
_ = record.city
_ = record.name
print(record.access_summary())
print("\n=== LazyRecord ===")
r = LazyRecord(99)
print("Antes del acceso, no hay query.")
print(f"name={r.name}, score={r.score}") # query ocurre aquí, una sola vez
print(f"active={r.active}") # ya estaba cargado
Desglose del código
El APIClient es el caso canónico de __getattr__. Fíjate que _base_url y _cache nunca activan el fallback porque se asignaron en __init__ y ya viven en self.__dict__. Solo los nombres desconocidos —client.users, client.orders— pasan por __getattr__. Eso es exactamente para lo que está diseñado: ampliar el espacio de atributos sin tocar los existentes.
Audited muestra por qué __getattribute__ es peligroso y poderoso al mismo tiempo. La primera línea del método recupera _access_log usando super().__getattribute__("_access_log"), no self._access_log. Si usaras self._access_log, invocarías tu propio __getattribute__ para resolver esa referencia, que intentaría leer _access_log, que llamaría a tu __getattribute__ otra vez. super() corta esa recursión delegando directamente a object.__getattribute__. Cada acceso a cualquier atributo no privado queda registrado, incluidos los que existen con total normalidad.
LazyRecord ilustra el patrón ORM. La carga ocurre exactamente una vez: la primera vez que accedes a un campo que no está en __dict__. Después de _load(), los datos se guardan en self._data (un diccionario privado), no directamente como atributos de instancia. Esto es deliberado: si hicieras self.name = self._data["name"] dentro de _load, el atributo pasaría a __dict__ y las siguientes lecturas nunca volverían a pasar por __getattr__. Guardar en _data y resolver siempre desde ahí mantiene el comportamiento uniforme.
Errores que debes conocer
Error: Acceder a self.x dentro de __getattribute__ sin usar super(), causando recursión infinita.
# ❌ Wrong
class Broken:
def __getattribute__(self, name: str):
if self._log: # self._log llama a __getattribute__ → recursión infinita
print(f"accediendo a {name}")
return self.__dict__[name] # __dict__ también pasa por __getattribute__
# ✅ Right
class Fixed:
def __getattribute__(self, name: str):
log = super().__getattribute__("_log") # delega a object
if log:
print(f"accediendo a {name}")
return super().__getattribute__(name)
super().__getattribute__ salta directamente a la implementación de object, que sabe cómo acceder a __dict__ sin volver a pasar por tu override.
Error: Lanzar una excepción que no sea AttributeError desde __getattr__, impidiendo que el mecanismo de fallback funcione correctamente.
# ❌ Wrong
class BadProxy:
def __getattr__(self, name: str):
if name not in self._allowed:
raise ValueError(f"{name} no está permitido") # rompe hasattr(), getattr() con default, etc.
return self._fetch(name)
# ✅ Right
class GoodProxy:
def __getattr__(self, name: str):
if name not in self._allowed:
raise AttributeError(f"{name} no está permitido") # protocolo correcto
return self._fetch(name)
hasattr(obj, x) se implementa llamando a getattr y capturando AttributeError; si lanzas otra excepción, hasattr la propaga en lugar de devolver False, rompiendo código genérico que usa el protocolo estándar de introspección.
Error: Usar __getattr__ para interceptar atributos que ya existen en __dict__, esperando que se llame siempre.
# ❌ Wrong — __getattr__ NUNCA se invoca para `name` porque ya está en __dict__
class Counter:
def __init__(self):
self.name = "original"
def __getattr__(self, attr: str):
if attr == "name": # esta rama nunca se ejecuta
return "interceptado"
raise AttributeError(attr)
# ✅ Right — si necesitas interceptar atributos existentes, usa __getattribute__
class Counter:
def __init__(self):
self.name = "original"
def __getattribute__(self, attr: str):
value = super().__getattribute__(attr)
if attr == "name":
return value.upper() # transforma el valor real
return value
__getattr__ es un fallback, no un interceptor universal; si el atributo existe, Python nunca llega a invocarlo.
N° 132