U2 - Programación Orientada a Objetos

1 Problemas con el descuento

Para inicializar el precio llamamos a super().__init__(precio) desde la subclase. Para calcular el precio final reutilizamos super().precio_final() y aplicamos el descuento al resultado. Así no repetimos la lógica del impuesto.

Aplicar primero el descuento también da 100 * 0.8 * 1.21 = 96.8, porque multiplicamos por los mismos factores.

class Articulo:
    def __init__(self, precio):
        self.precio = precio

    def precio_final(self):
        return self.precio * 1.21

class ArticuloEnOferta(Articulo):
    def __init__(self, precio, descuento):
        super().__init__(precio)
        self.descuento = descuento

    def precio_final(self):
        return super().precio_final() * (1 - self.descuento)

# Precio final debe ser igual a 100 * 1.21 * 0.8 = 96.8.
# Por precisión numérica de la computadora, necesitamos usar un margen de error.
eps = 0.0000001
assert 96.8 - eps < ArticuloEnOferta(100, 0.20).precio_final() < 96.8 + eps
print(round(ArticuloEnOferta(100, 0.20).precio_final(), 2))
96.8

2 Reportes

Para agregar otro tipo de reporte implementamos exportar en una nueva subclase. Como guardar_todos llama al mismo método para cada reporte, no necesitamos modificar la función.

Si agregamos un str a la lista, aparece un AttributeError en reporte.exportar(), porque las cadenas no tienen ese método. La clase abstracta impide crear subclases que no implementen exportar, pero no controla qué objetos ponemos en la lista. Las anotaciones de tipo tampoco verifican los datos durante la ejecución. Para detectar el problema antes de exportar podemos recorrer la lista y verificar cada elemento con isinstance(reporte, Reporte).

from abc import ABC, abstractmethod

class Reporte(ABC):
    @abstractmethod
    def exportar(self):
        raise NotImplementedError

class ReporteTexto(Reporte):
    def exportar(self):
        return "texto plano"

class ReporteCSV(Reporte):
    def exportar(self):
        return "a,b,c"

class ReporteJSON(Reporte):
    def exportar(self):
        return '{"datos": []}'

def guardar_todos(reportes):
    exportados = []
    for reporte in reportes:
        exportados.append(reporte.exportar())
    return exportados

reportes = [ReporteTexto(), ReporteCSV(), ReporteJSON()]
print(guardar_todos(reportes))
['texto plano', 'a,b,c', '{"datos": []}']

3 Notificaciones

Para que todos los notificadores puedan usarse de la misma manera definimos el método enviar(mensaje) en la clase base. Cada subclase implementa su propia forma de enviar el mensaje. Así podemos agregar nuevos canales sin modificar notificar_a_todos.

Si un envío puede fallar, podemos capturar la excepción dentro del for para continuar con los demás. Si queremos detener los envíos ante un error, dejamos que la excepción se propague.

from abc import ABC, abstractmethod

class Notificador(ABC):
    @abstractmethod
    def enviar(self, mensaje):
        raise NotImplementedError

class Email(Notificador):
    def enviar(self, mensaje):
        print(f"Email: {mensaje}")

class Consola(Notificador):
    def enviar(self, mensaje):
        print(mensaje)

def notificar_a_todos(notificadores, mensaje):
    for notificador in notificadores:
        notificador.enviar(mensaje)

notificar_a_todos([Email(), Consola()], "Hola")
Email: Hola
Hola

4 Coordenadas

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

origen_a = Coordenada(0, 0)
origen_b = Coordenada(0, 0)
print(origen_a == origen_b)
print(origen_a is origen_b)
print(origen_a.__dict__ == origen_b.__dict__)
False
False
True

Como no implementamos __eq__, == compara la identidad de los objetos. origen_a y origen_b son objetos distintos, por lo que tanto == como is devuelven False. En cambio, sus diccionarios de atributos tienen los mismos valores y la comparación devuelve True.

Para que dos coordenadas con los mismos valores sean iguales implementamos __eq__:

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

    def __eq__(self, other):
        if not isinstance(other, Coordenada):
            return NotImplemented
        return self.x == other.x and self.y == other.y

print(Coordenada(0, 0) == Coordenada(0, 0))
print(Coordenada(0, 0).__eq__((0, 0)))
print(Coordenada(0, 0) == (0, 0))
True
NotImplemented
False

Verificamos el tipo con isinstance para comparar los atributos solamente cuando el otro objeto también es una Coordenada. Si es de otro tipo, devolvemos NotImplemented para indicar que el método no define esa comparación.

La llamada Coordenada(0, 0).__eq__((0, 0)) devuelve NotImplemented. En cambio, la expresión Coordenada(0, 0) == (0, 0) devuelve False: el operador == intenta resolver la comparación y no encuentra una igualdad definida entre esos objetos.

5 Terrenos

class Terreno:
    def __init__(self, frente, fondo):
        self.frente = frente
        self.fondo = fondo

    def area(self):
        return self.frente * self.fondo

    def __lt__(self, other):
        if not isinstance(other, Terreno):
            return NotImplemented
        return self.area() < other.area()
terreno_a = Terreno(2, 6)
terreno_b = Terreno(3, 4)
terreno_c = Terreno(5, 5)
terrenos = [terreno_c, terreno_a, terreno_b]

areas = []
for terreno in sorted(terrenos):
    areas.append(terreno.area())

print(areas)
[12, 12, 25]
print(terreno_a < terreno_b)
False
print(terreno_b < terreno_a)
False
print(terreno_a.area() == terreno_b.area())
True
print(terreno_a == terreno_b)
False

Para ordenar los terrenos con sorted implementamos __lt__, que compara sus áreas. Los terrenos de 2 × 6 y 3 × 4 tienen la misma área, por lo que ninguno es menor que el otro. En ese caso, sorted conserva el orden que tenían en la lista.

Como no implementamos __eq__, == compara la identidad de los objetos y devuelve False para estos dos terrenos. No es necesario que tener la misma área implique ser iguales: también podrían importar las dimensiones o la ubicación. En otro dominio donde solo interese la superficie, podríamos implementar __eq__ comparando las áreas.

6 Compras

Para que len(carrito) devuelva la cantidad de productos en el carrito implementamos __len__. En este caso, también cuenta los duplicados.

Para utilizar el operador in se implementa el método __contains__, que devuelve un booleano. La búsqueda se hace por nombre para que funcione la expresión "yerba" in carrito. Se podría implementar búsqueda por Producto pero escribir Producto("yerba") in carrito puede resultar más incómodo para el usuario.

Finalmente, para obtener una descripción legible al usar print() podemos implementar el método __str__.

class Producto:
    def __init__(self, nombre):
        self.nombre = nombre

class Carrito:
    def __init__(self, productos):
        # Me aseguro que la secuencia es una lista internamente
        self.productos = list(productos)

    def __len__(self):
        return len(self.productos)

    def __contains__(self, nombre):
        for producto in self.productos:
            if producto.nombre == nombre:
                return True
        return False

    def __str__(self):
        nombres = []
        for producto in self.productos:
            nombres.append(producto.nombre)
        return "Carrito con: " + ", ".join(nombres)
carrito = Carrito([Producto("yerba"), Producto("azúcar")])
print(len(carrito))
2
print("yerba" in carrito)
True
print(carrito)
Carrito con: yerba, azúcar

7 Tiempo

Para sumar dos duraciones implementamos __add__, que devuelve una nueva Duracion con la suma de los minutos. Para mostrarla con print() usamos __str__, y para obtener una representación como Duracion(60) usamos __repr__.

En este caso decidimos permitir solamente la suma entre duraciones. Si el otro objeto es un entero, __add__ devuelve NotImplemented y la expresión Duracion(15) + 10 produce un TypeError.

class Duracion:
    def __init__(self, minutos):
        self.minutos = minutos

    def __add__(self, other):
        if not isinstance(other, Duracion):
            return NotImplemented
        return Duracion(self.minutos + other.minutos)

    def __repr__(self):
        return f"Duracion({self.minutos})"

    def __str__(self):
        return f"{self.minutos // 60} h {self.minutos % 60} min"

total = Duracion(15) + Duracion(45)
print(total)
print(repr(total))
1 h 0 min
Duracion(60)

8 Contador

class Contador:
    def __init__(self, valor_inicial=0):
        self._inicial = valor_inicial
        self._valor = valor_inicial

    def incrementar(self, paso=1):
        self._valor += paso
        return self._valor

    def decrementar(self, paso=1):
        self._valor -= paso
        return self._valor

    def valor(self):
        return self._valor

    def reiniciar(self):
        self._valor = self._inicial
        return self._valor

    def __str__(self):
        return f"Contador(valor={self._valor})"

Creamos un contador en cero y cambiamos su valor:

c = Contador()
print(c.valor())
print(c.incrementar(5))
print(c.decrementar(2))
print(c.reiniciar())
0
5
3
0

Si no indicamos el paso, incrementamos o decrementamos de a uno:

print(c.incrementar())
print(c.incrementar())
print(c.decrementar())
print(c)
1
2
1
Contador(valor=1)

También podemos empezar en otro valor. Al reiniciar volvemos a ese valor inicial:

contador_iniciado_en_10 = Contador(10)
print(contador_iniciado_en_10.valor())
print(contador_iniciado_en_10.incrementar(3))
print(contador_iniciado_en_10.reiniciar())
10
13
10

Guardamos el valor inicial en _inicial para poder recuperarlo al llamar a reiniciar, aunque sea distinto de cero.

Los valores por defecto permiten crear un contador en cero e incrementar o decrementar de a uno cuando no indicamos otra cantidad.

9 Magia para programadores

import math

class Pocion:
    def __init__(self, color, volumen):
        self.color = color
        self.volumen = volumen

    def mezclar(self, other):
        volumen_total = self.volumen + other.volumen
        w1 = self.volumen / volumen_total
        w2 = other.volumen / volumen_total

        color = []
        for i in range(3):
            componente = math.ceil(self.color[i] * w1 + other.color[i] * w2)
            color.append(componente)
        return Pocion(color=color, volumen=volumen_total)

felix_felicis = Pocion([255, 255, 255],  7)
pocion_multijugos = Pocion([51, 102, 51], 12)
nueva_pocion = felix_felicis.mezclar(pocion_multijugos)

assert nueva_pocion.color == [127, 159, 127]
assert nueva_pocion.volumen == 19
print(nueva_pocion.color)
print(nueva_pocion.volumen)
[127, 159, 127]
19

Para obtener el volumen de la mezcla sumamos los volúmenes de las dos pociones. Para el color calculamos un promedio de cada componente RGB, dando más peso a la poción que tiene mayor volumen. Usamos math.ceil para redondear hacia arriba y devolvemos una nueva Pocion con el resultado.

10 Mensaje secreto

class Cifrado:
    def __init__(self, original, alternativo):
        if len(original) != len(alternativo):
            raise ValueError(
                f"len(original) != len(alternativo) ({len(original)} vs {len(alternativo)})"
            )

        self.original = tuple(original)
        self.alternativo = tuple(alternativo)

    def codificar(self, texto):
        caracteres_codificados = []
        for caracter in texto:
            indice = self.original.index(caracter)
            caracteres_codificados.append(self.alternativo[indice])
        return "".join(caracteres_codificados)

    def decodificar(self, texto):
        caracteres_decodificados = []
        for caracter in texto:
            indice = self.alternativo.index(caracter)
            caracteres_decodificados.append(self.original[indice])
        return "".join(caracteres_decodificados)

Creamos el cifrado y codificamos algunos textos:

alfabeto = "abcdefghijklmnopqrstuvwxyz"
alfabeto_mezclado = "etaoinshrdlucmfwypvbgkjqxz"

mi_cifrado = Cifrado(alfabeto, alfabeto_mezclado)

print(mi_cifrado.codificar("abc"))
print(mi_cifrado.codificar("xyz"))
print(mi_cifrado.codificar("aeiou"))
eta
qxz
eirfg

Para recuperar los textos originales usamos decodificar:

print(mi_cifrado.decodificar("eta"))
print(mi_cifrado.decodificar("qxz"))
print(mi_cifrado.decodificar("eirfg"))
abc
xyz
aeiou

Para codificar buscamos la posición de cada carácter en original y tomamos el carácter de esa misma posición en alternativo. Para decodificar hacemos lo mismo en sentido inverso. Finalmente, usamos "".join(...) para unir los caracteres en una cadena.

En __init__ verificamos que los alfabetos tengan la misma longitud. Si no la tienen, levantamos un ValueError con un mensaje que indica el problema.

11 Real envido

class ManoDeTruco:
    def __init__(self, cartas):
        self.cartas = cartas

    def comparar_con(self, other):
        if self.puntos() >= other.puntos():
            return self
        return other

    def puntos(self):
        n_altas = 0
        for carta in self.cartas:
            if carta >= 10:
                n_altas += 1
        if n_altas == 3:
            return 20
        elif n_altas == 2:
            return 20 + min(self.cartas)
        elif n_altas == 1:
            return 20 + sum(sorted(self.cartas)[:2])
        return 20 + sum(sorted(self.cartas)[-2:])
mano1 = ManoDeTruco([7, 5, 6])
mano2 = ManoDeTruco([4, 11, 2])

print(mano1.puntos())
33
print(mano2.puntos())
26
print(mano1.comparar_con(mano2) is mano1)
True

Para calcular los puntos contamos las cartas de valor 10 o más, que no suman puntos individuales. A los 20 puntos por tener cartas del mismo palo les sumamos los dos valores más altos que correspondan.

En comparar_con usamos >= para devolver la mano que llamó al método cuando hay empate.

12 La muestra infinita

class Muestra:
    def __init__(self, iterable):
        self._datos = list(iterable)

    def agregar(self, x):
        self._datos.append(x)

    def n(self):
        return len(self._datos)

    def suma(self):
        return sum(self._datos)

    def media(self):
        n = self.n()
        return self.suma() / n

    def varianza(self, muestral=False):
        """Calcula la varianza poblacional o muestral."""

        n = self.n()
        mu = self.media()
        ssd = 0
        for x in self._datos:
            ssd += (x - mu) ** 2

        if muestral:
            denom = n - 1
        else:
            denom = n

        return ssd / denom

    @property
    def valores(self):
        """Devuelve los datos como una tupla."""
        return tuple(self._datos)
m = Muestra([10, 12, 13, 15])
m.agregar(20)
print(m.n())
5
print(m.suma())
70
print(m.media())
14.0
print(m.varianza())
11.6
print(m.varianza(muestral=True))
14.5
print(m.valores)
(10, 12, 13, 15, 20)

Guardamos los datos en una lista para poder agregar valores con append. Para calcular la varianza dividimos la suma de las desviaciones al cuadrado por n o por n - 1, según sea poblacional o muestral.

Para consultar los datos sin modificar la lista mediante valores, usamos una propiedad sin setter que devuelve una tupla. El prefijo _ indica que _datos es de uso interno, aunque Python no impide acceder al atributo.

13 ¡Orden en el laboratorio!

class Experimento:
    total_creados = 0

    def __init__(self, nombre, responsable=None):
        Experimento.total_creados += 1
        self.id = Experimento.total_creados
        self.nombre = nombre
        self.responsable = responsable

    @classmethod
    def desde_dict(cls, datos):
        return cls(datos.get("nombre"), responsable=datos.get("responsable"))

    def __repr__(self):
        import json

        nombre = json.dumps(self.nombre, ensure_ascii=False)
        responsable = ("None" if self.responsable is None else
                       json.dumps(self.responsable, ensure_ascii=False))
        return (f"Experimento(id={self.id}, nombre={nombre}, "
                f"responsable={responsable})")
e1 = Experimento("Piloto A", responsable="Dolores")
e2 = Experimento("Piloto Z")
e3 = Experimento.desde_dict({"nombre": "Piloto B", "responsable": "Ana"})

print(Experimento.total_creados)
3
print(repr(e1))
Experimento(id=1, nombre="Piloto A", responsable="Dolores")
print(repr(e3))
Experimento(id=3, nombre="Piloto B", responsable="Ana")

Para asignar un identificador distinto a cada experimento usamos el atributo de clase total_creados. Como lo comparten todas las instancias, lo incrementamos cada vez que creamos una nueva y guardamos ese valor en id.

Para crear un experimento desde un diccionario implementamos desde_dict con @classmethod. Usamos cls(...) para construir la instancia y get para que el responsable sea None si no se indica. Finalmente, __repr__ devuelve la representación con los datos del experimento.

14 Sensores descalibrados

import random

class Sensor:
    def __init__(self, nombre):
        self.nombre = nombre
        self._offset = 0.0

    def calibrar(self, offset):
        self._offset = float(offset)

    def leer(self):
        raise NotImplementedError("Esta clase no implementa 'leer'. Use una subclase.")

class SensorTemperatura(Sensor):
    def leer(self):
        base = random.uniform(18, 28)
        return base + self._offset

class SensorHumedad(Sensor):
    def leer(self):
        base = random.uniform(30, 70)
        return base + self._offset

def promedio_lecturas(sensores, n=5):
    """Devuelve el promedio de n lecturas para cada sensor."""

    if not isinstance(n, int):
        raise TypeError("n debe ser un entero")
    if n <= 0:
        raise ValueError("n debe ser positivo")

    resultados = {}

    for sensor in sensores:
        acumulado = 0
        for _ in range(n):
            acumulado += sensor.leer()
        resultados[sensor.nombre] = acumulado / n
    return resultados

random.seed(0)

t = SensorTemperatura("T1")
h = SensorHumedad("H1")

t.calibrar(0.5)

promedios = promedio_lecturas([t, h], n=3)
print(promedios)
{'T1': 25.24315945098732, 'H1': 45.66834145482648}

Guardamos el ajuste en _offset para sumarlo a cada lectura. Cada subclase implementa leer con el rango que corresponde a su sensor. El método de la clase base levanta NotImplementedError para indicar que las subclases deben implementarlo.

En promedio_lecturas llamamos a leer la cantidad de veces indicada y guardamos cada promedio en un diccionario. Como ambos sensores tienen ese método, no necesitamos distinguirlos por tipo. Verificamos que n sea un entero positivo para poder calcular el promedio.

15 Python para matemáticos

def mcd(a, b):
    while b != 0:
        a, b = b, a % b
    return abs(a)

class Fraccion:
    def __init__(self, numerador, denominador):
        if denominador == 0:
            raise ValueError("El denominador no puede ser cero")
        self.numerador = numerador
        self.denominador = denominador
        self._simplificar()

    def _simplificar(self):
        divisor = mcd(self.numerador, self.denominador)
        self.numerador = self.numerador // divisor
        self.denominador = self.denominador // divisor

        if self.denominador < 0:
            self.numerador *= -1
            self.denominador *= -1

    def __str__(self):
        return f"{self.numerador}/{self.denominador}"

    def __repr__(self):
        return f"Fraccion({self.numerador}, {self.denominador})"

    def __add__(self, other):
        if not isinstance(other, Fraccion):
            return NotImplemented
        num = self.numerador * other.denominador + other.numerador * self.denominador
        den = self.denominador * other.denominador
        return Fraccion(num, den)

    def __sub__(self, other):
        if not isinstance(other, Fraccion):
            return NotImplemented
        num = self.numerador * other.denominador - other.numerador * self.denominador
        den = self.denominador * other.denominador
        return Fraccion(num, den)

    def __mul__(self, other):
        if not isinstance(other, Fraccion):
            return NotImplemented
        num = self.numerador * other.numerador
        den = self.denominador * other.denominador
        return Fraccion(num, den)

    def __truediv__(self, other):
        if not isinstance(other, Fraccion):
            return NotImplemented

        if other.numerador == 0:
            raise ZeroDivisionError("No se puede dividir por cero")

        num = self.numerador * other.denominador
        den = self.denominador * other.numerador
        return Fraccion(num, den)
f1 = Fraccion(4, 5)
f2 = Fraccion(1, 8)

print(f1 + f2)
37/40
print(f1 - f2)
27/40
print(f1 * f2)
1/10
print(f1 / f2)
32/5
print(f1 / Fraccion(2, 1))
2/5
print(f1 * Fraccion(5, 1))
4/1

Para simplificar la fracción dividimos el numerador y el denominador por su máximo común divisor. Además, dejamos el signo en el numerador para que el denominador sea positivo.

Para usar los operadores +, -, * y / implementamos __add__, __sub__, __mul__ y __truediv__. Cada método devuelve una nueva Fraccion, por lo que el constructor también simplifica el resultado. Verificamos que el denominador no sea cero y que no se intente dividir por una fracción de valor cero.

16 Tiempo al tiempo ⏳

DOM = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30,
       7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31}

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    @property
    def year(self):
        return self._year

    @year.setter
    def year(self, value):
        if type(value) is not int:
            raise ValueError("year debe ser un entero")
        if value < 0:
            raise ValueError("year debe ser mayor o igual que cero")
        if hasattr(self, "_day") and self._day > self._dias_del_mes(value, self.month):
            raise ValueError("day no es válido para el nuevo year")
        self._year = value

    @property
    def month(self):
        return self._month

    @month.setter
    def month(self, value):
        if type(value) is not int:
            raise ValueError("month debe ser un entero")
        if not 1 <= value <= 12:
            raise ValueError("month debe estar entre 1 y 12")
        if hasattr(self, "_day") and self._day > self._dias_del_mes(self.year, value):
            raise ValueError("day no es válido para el nuevo month")
        self._month = value

    @property
    def day(self):
        return self._day

    @day.setter
    def day(self, value):
        if type(value) is not int:
            raise ValueError("day debe ser un entero")
        if not 1 <= value <= self._dias_del_mes(self.year, self.month):
            raise ValueError("day no es válido para el mes")
        self._day = value

    @classmethod
    def from_str(cls, value):
        if not isinstance(value, str):
            raise TypeError("la fecha debe ser texto")
        try:
            year, month, day = map(int, value.split("-"))
        except (ValueError, TypeError) as error:
            raise ValueError("use el formato YYYY-MM-DD") from error
        return cls(year, month, day)

    @staticmethod
    def _es_bisiesto(year):
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

    @staticmethod
    def _dias_del_mes(year, month):
        if month == 2 and Date._es_bisiesto(year):
            return 29
        return DOM[month]

    def _clave(self):
        return self.year, self.month, self.day

    def __eq__(self, other):
        if not isinstance(other, Date):
            return NotImplemented
        return self._clave() == other._clave()

    def __ne__(self, other):
        result = self.__eq__(other)
        return NotImplemented if result is NotImplemented else not result

    def __lt__(self, other):
        if not isinstance(other, Date):
            return NotImplemented
        return self._clave() < other._clave()

    def __le__(self, other):
        if not isinstance(other, Date):
            return NotImplemented
        return self._clave() <= other._clave()

    def __gt__(self, other):
        if not isinstance(other, Date):
            return NotImplemented
        return self._clave() > other._clave()

    def __ge__(self, other):
        if not isinstance(other, Date):
            return NotImplemented
        return self._clave() >= other._clave()

    def __str__(self):
        return f"{self.year:04d}-{self.month:02d}-{self.day:02d}"

    def __repr__(self):
        return f"Date(year={self.year}, month={self.month}, day={self.day})"
d1 = Date(1990, 5, 20)
d2 = Date(1998, 9, 21)
d3 = Date(1998, 9, 20)
d4 = Date(2022, 12, 18)
fecha = Date(1995, 11, 6)
print((fecha.year, fecha.month, fecha.day))
(1995, 11, 6)
print(str(fecha))
1995-11-06
print(repr(fecha))
Date(year=1995, month=11, day=6)
print(Date.from_str("2025-03-08"))
2025-03-08
print(d1 < d2)
True
print(d1 > d2)
False
print(d1 != d3)
True
print(d4 >= d3)
True
print(Date(2000, 2, 29))
2000-02-29
print(sorted([d1, d2, d3, d4]))
[Date(year=1990, month=5, day=20), Date(year=1998, month=9, day=20), Date(year=1998, month=9, day=21), Date(year=2022, month=12, day=18)]

Para mostrar la fecha implementamos __str__ con el formato YYYY-MM-DD y __repr__ con los argumentos de la clase. El formato 02d agrega ceros a la izquierda hasta completar dos dígitos; 04d hace lo mismo con cuatro. Para crear una fecha desde texto, from_str separa los valores por -, los convierte a enteros y llama al constructor.

Para comparar fechas usamos las tuplas (year, month, day). Python compara primero el año; si coincide, compara el mes y luego el día. Así también podemos ordenar fechas con sorted.

Guardamos los valores en _year, _month y _day, y usamos propiedades con setters para verificar cada asignación. Si el valor no es válido, levantamos un ValueError antes de modificar el atributo. Para validar el día consultamos los días del mes y verificamos si el año es bisiesto cuando se trata de febrero. También revisamos el día al cambiar el mes o el año, para no dejar una fecha inválida.

17 Buscando el error

nombre = "Ada"
print(nombre)
Ada
edades = [19, 21, 23]
ultima_edad = edades[2]
print(ultima_edad)
23
total = 1500
print("Total: $" + str(total))
Total: $1500
temperaturas = [18.5, 19.2, 20.0]
temperaturas.append(20.4)
print(temperaturas)
[18.5, 19.2, 20.0, 20.4]
def saludar(nombre):
    return f"¡Hola, {nombre}!"

print(saludar(nombre))
¡Hola, Ada!

En cada caso corregimos la causa del error:

  • NameError: escribimos nombre en lugar de nomrbe. El mensaje indica el nombre que no está definido.
  • IndexError: usamos el índice 2, porque la lista tiene tres elementos y los índices empiezan en cero. El mensaje indica que el índice está fuera del rango.
  • TypeError: convertimos total a texto con str para concatenarlo. El mensaje indica que no podemos concatenar una cadena y un entero.
  • AttributeError: usamos una lista en lugar de una tupla para poder llamar a append. El mensaje indica el método que la tupla no tiene.
  • SyntaxError: agregamos los dos puntos después de def saludar(nombre). El mensaje señala dónde se esperaba :.

18 Cada problema con su nombre

class Inventario:
    def __init__(self, stock):
        if not isinstance(stock, dict):
            raise TypeError("stock debe ser un diccionario")
        for producto, cantidad in stock.items():
            if not isinstance(producto, str):
                raise TypeError("el nombre del producto debe ser texto")
            if not producto:
                raise ValueError("el nombre del producto no puede estar vacío")
            if type(cantidad) is not int:
                raise TypeError("la cantidad debe ser entera")
            if cantidad < 0:
                raise ValueError("la cantidad no puede ser negativa")
        self.stock = stock.copy()

    def hay_stock(self, producto, cantidad):
        if not isinstance(producto, str):
            raise TypeError("producto debe ser texto")
        if not producto:
            raise ValueError("producto no puede estar vacío")
        if type(cantidad) is not int:
            raise TypeError("cantidad debe ser entera")
        if cantidad <= 0:
            raise ValueError("cantidad debe ser positiva")
        if producto not in self.stock:
            raise KeyError(f"producto inexistente: {producto}")
        return self.stock[producto] >= cantidad

inventario = Inventario({"cuadernos": 12, "lápices": 30, "carpetas": 5})
print(inventario.hay_stock("cuadernos", 8))  
print(inventario.hay_stock("carpetas", 9))
True
False

Guardamos una copia del diccionario para que los cambios en el original no modifiquen el stock del inventario. En hay_stock verificamos los argumentos antes de comparar la cantidad pedida con la disponible. Si los datos son válidos, devolvemos True o False según alcance el stock.

Usamos TypeError cuando el dato tiene un tipo incorrecto, ValueError cuando el tipo es correcto pero el valor no es válido y KeyError cuando el producto no existe. El stock puede ser cero, pero la cantidad que pedimos debe ser mayor que cero.

19 Un cajero insistente

class CuentaError(Exception):
    pass

class MontoInvalidoError(CuentaError):
    pass

class SaldoInsuficienteError(CuentaError):
    pass

class CuentaBancaria:
    def __init__(self, saldo):
        self.saldo = saldo

    def extraer(self, monto):
        if monto <= 0:
            raise MontoInvalidoError("el monto debe ser mayor que cero")
        if monto > self.saldo:
            raise SaldoInsuficienteError("saldo insuficiente")
        self.saldo -= monto
        return monto

def solicitar_extraccion(cuenta):
    while True:
        try:
            monto = float(input("Monto a extraer: "))
            extraido = cuenta.extraer(monto)
        except ValueError as error:
            print(f"Entrada no numérica: {error}")
        except MontoInvalidoError as error:
            print(f"Monto inválido: {error}")
        except SaldoInsuficienteError as error:
            print(f"Saldo insuficiente: {error}")
        else:
            return extraido

if __name__ == "__main__":
    cuenta = CuentaBancaria(100)
    solicitar_extraccion(cuenta)
    print(f"Saldo restante: {cuenta.saldo}")
Monto a extraer: abc
Entrada no numérica: could not convert string to float: 'abc'
Monto a extraer: -5
Monto inválido: el monto debe ser mayor que cero
Monto a extraer: 150
Saldo insuficiente: saldo insuficiente
Monto a extraer: 40
Saldo restante: 60.0

Para distinguir los problemas de la cuenta definimos dos excepciones que heredan de CuentaError. En extraer verificamos el monto antes de descontarlo, así un intento inválido no modifica el saldo.

Usamos while True para seguir pidiendo un monto y un except para cada problema. El else se ejecuta cuando no hubo una excepción y devuelve el monto extraído, terminando la función. Para probarlo, ingresamos un texto no numérico, un monto negativo, uno mayor al saldo y finalmente uno válido.