Hola! Vamos a hacernos amigas de los errores
Te lo dije en el capítulo 2 y lo repito porque importa: los errores no son un castigo, son información 💜
Un error que revienta es el mejor de los casos. El peligroso es el que no revienta y te deja un número mal, que es del que va buena parte de este capítulo.
Y para en seco un segundo antes de seguir: ¿alguna vez entregaste un número sin comprobar de dónde salía? Todas lo hicimos alguna vez. Este capítulo va de que no vuelva a pasar sin que te enteres 💜
Los seis errores que de verdad vas a ver
| Error | Qué significa | Cuándo aparece con datos |
|---|---|---|
TypeError | Le pediste algo imposible a ese tipo | Sumar un texto con un número |
ValueError | El tipo está bien, el valor no | Convertir 'n/a' a número |
KeyError | Esa clave no existe | Una columna que no vino en el archivo |
IndexError | Esa posición no existe | Una fila con menos campos de los esperados |
FileNotFoundError | No está el archivo | La ruta, siempre la ruta |
ZeroDivisionError | División entre cero | Un promedio de una lista vacía |
Vamos a verlos de verdad, que es como se aprenden:
int('n/a')
ValueError: invalid literal for int() with base 10: 'n/a'
venta = {'ciudad': 'Lima'}
venta['descuento']
KeyError: 'descuento'
sum([]) / len([])
ZeroDivisionError: division by zero
Fíjate en la diferencia entre los dos primeros. int('12')
funciona, o sea que int() sí acepta textos: el tipo estaba bien y lo
que estaba mal era el valor. Eso es un ValueError.
En cambio '12' + 5 es un TypeError, porque la
operación misma no tiene sentido entre esos dos tipos.
try y except
valores = ['1250.50', '480,37', 'n/a', '95.5', ''] limpios = [] descartados = [] for v in valores: try: limpios.append(float(v)) except ValueError: descartados.append(v) print('limpios :', limpios) print('descartados :', descartados)
limpios : [1250.5, 95.5] descartados : ['480,37', 'n/a', '']
Eso ya es código útil de verdad: procesa lo que puede y guarda lo que no pudo para mirarlo después.
Y mira qué se ve ahí: '480,37' no era basura, era un número con
coma decimal. Si lo hubiera botado sin mirar, habría perdido una venta buena. Por
eso los descartes se guardan, no se ignoran 🔍
for v in descartados: print(repr(v), '->', float(v.replace(',', '.')) if v.replace(',', '').replace('.', '').isdigit() else 'sin arreglo')
'480,37' -> 480.37 'n/a' -> sin arreglo '' -> sin arreglo
El anti-patrón que más daño hace
Esto lo vas a ver en internet y en código de gente con años de experiencia. No lo copies.
valores = ['1250.50', 'n/a', '95.5'] total = 0 for v in valores: try: total = total + float(v) except: pass print(total)
1346.0
Funciona. Ese es justo el problema 😖
Un except pelado se traga todo: el valor
inválido que esperabas, pero también un error de tipeo tuyo, un problema de
memoria, una interrupción con Ctrl+C. Y pass significa "no hagas
nada al respecto".
Entonces un día tu proceso devuelve un total que es la mitad del real y no hay ni una línea en ningún lado que diga qué pasó. Lo he visto costar semanas de confianza en un equipo de datos.
Las dos reglas, y son duras:
- 🎯 Atrapa el error concreto que esperas:
except ValueError, noexcepta secas. - 📢 Haz algo con él. Guárdalo, cuéntalo, imprímelo. Nunca
pass.
valores = ['1250.50', 'n/a', '95.5'] total = 0 problemas = [] for v in valores: try: total = total + float(v) except ValueError as e: problemas.append((v, str(e))) print(f'Total: {total}') print(f'Problemas: {len(problemas)}') for valor, mensaje in problemas: print(f' {repr(valor)}: {mensaje}')
Total: 1346.0 Problemas: 1 'n/a': could not convert string to float: 'n/a'
El mismo total, pero ahora sabes que hubo un problema y cuál fue. Esa diferencia es la que separa un script de un proceso en el que se puede confiar 🌸
else y finally
def leer_monto(texto): try: valor = float(texto) except ValueError: print(f' {repr(texto)} no es un numero') return None else: print(f' {repr(texto)} convertido bien') return valor finally: print(' (esto sale siempre)') print('Caso bueno:') leer_monto('480.37') print('Caso malo:') leer_monto('n/a')
Caso bueno: '480.37' convertido bien (esto sale siempre) Caso malo: 'n/a' no es un numero (esto sale siempre)
- ✅
elsecorre solo si no hubo error. - 🔒
finallycorre siempre, con error o sin él, incluso si hay unreturnen medio. Es donde se cierran archivos y conexiones.
Levantar tus propios errores
A veces el error no lo produce Python:
- Lo produces tú
- Porque los datos no tienen sentido
- Seguir sería peor
def calcula_descuento(monto, porcentaje): if not 0 <= porcentaje <= 1: raise ValueError( f'El porcentaje debe ir entre 0 y 1, y llegó {porcentaje}. ' f'Si viene como 15 en vez de 0.15, divídelo entre 100.') return round(monto * porcentaje, 2) print(calcula_descuento(1000, 0.15)) calcula_descuento(1000, 15)
ValueError: El porcentaje debe ir entre 0 y 1, y llegó 15. Si viene como 15 en vez de 0.15, divídelo entre 100.
Sin ese raise, el descuento de 15 habría dado S/15.000 sobre una
venta de S/1.000. Habría corrido perfecto y el reporte habría salido absurdo.
Un buen mensaje de error dice tres cosas: qué se esperaba, qué llegó, y qué hacer al respecto. Ese de arriba dice las tres 💜
Cómo depurar sin volverte loca
Cuando algo no funciona y no entiendes por qué, este es el orden que a mí me funciona.
1. Imprime el tipo antes que el valor.
dato = '1250.50' print(type(dato), repr(dato))
<class 'str'> '1250.50'
El repr es clave: te muestra las comillas y los espacios
invisibles. La mitad de los misterios se resuelven aquí.
2. Parte el problema en pedazos. Si una línea larga falla, pártela y mira cada trozo.
fila = ' C0045 ; Arequipa ; 480,37 ' paso1 = fila.strip() paso2 = paso1.split(';') paso3 = [p.strip() for p in paso2] print(repr(paso1)) print(paso2) print(paso3) print(float(paso3[2].replace(',', '.')))
'C0045 ; Arequipa ; 480,37' ['C0045 ', ' Arequipa ', ' 480,37'] ['C0045', 'Arequipa', '480,37'] 480.37
Ahí se ve clarito que después de partir por punto y coma quedaban espacios, y
que por eso hacía falta el segundo strip.
3. Reduce el caso. Si falla con tres mil filas, prueba con la primera. Casi siempre falla también, y depurar una fila es mil veces más rápido que depurar tres mil.
4. Lee el traceback de abajo hacia arriba y busca la última línea que menciona tu código. Eso ya lo tienes del capítulo 2.
5. Cuando nada funcione, explícaselo en voz alta a alguien. Te vas a dar cuenta sola a mitad de la explicación. A mí me pasa siempre, y no tengo idea de por qué funciona tan bien 😄
La traza es un mapa, no un castigo
Arriba te dije "lee el traceback de abajo hacia arriba". Ahora te lo enseño con uno de verdad, porque cuando hay funciones llamando a funciones el mapa tiene varias paradas 🗺️
import traceback def limpia_monto(texto): return float(texto.replace(',', '.')) def total_de(filas): return sum(limpia_monto(f['monto']) for f in filas) filas = [{'monto': '480.37'}, {'monto': '524,90'}, {'monto': 'n/a'}] try: total_de(filas) except ValueError as e: print('el error :', type(e).__name__) print('el mensaje:', e) print() print('por donde paso, de fuera hacia dentro:') for marco in traceback.extract_tb(e.__traceback__): print(f' linea {marco.lineno:>3} dentro de {marco.name}')
el error : ValueError el mensaje: could not convert string to float: 'n/a' por donde paso, de fuera hacia dentro: linea 15 dentro de <module> linea 9 dentro de total_de linea 9 dentro de <genexpr> linea 5 dentro de limpia_monto
Eso es exactamente lo que te sale en rojo cuando algo revienta, solo que aquí
lo pedí ordenadito con traceback 🔍
Léelo así:
- 🔻 De arriba abajo es de fuera adentro. Arriba está la línea que tú escribiste, abajo la línea que finalmente falló.
- 🎯 La última parada es dónde reventó. Ahí está el bug si el código es tuyo.
- 👀 La primera parada tuya, contando desde abajo, es dónde mirar. Cuando la última parada está dentro de pandas o de numpy, el problema casi nunca es de pandas: es lo que tú le pasaste.
Y ese <genexpr> que aparece en medio no es un error tuyo: es
la expresión generadora de dentro del sum(). Verás también
<listcomp> por las comprensiones de lista y
<module> por el nivel de arriba del todo, el del cuaderno.
El error que no dice qué fila
Mira el mensaje de arriba otra vez: could not convert string to float: 'n/a'. Te dice el valor. No te dice cuál de las tres mil filas es, y ese es el dato que necesitas para arreglarlo 😖
La solución es atraparlo, añadirle lo que tú sí sabes, y volver a lanzarlo:
def monto_de_la_fila(numero, texto): try: return float(texto.replace(',', '.')) except ValueError as e: raise ValueError(f'fila {numero}: el monto dice {texto!r}') from e try: monto_de_la_fila(1847, 'n/a') except ValueError as e: print('lo que ves tu :', e) print('lo que paso antes:', e.__cause__)
lo que ves tu : fila 1847: el monto dice 'n/a' lo que paso antes: could not convert string to float: 'n/a'
Ahí tienes las dos mitades 💡
El mensaje de arriba es el útil, el que dice fila 1847. Y el de abajo, el
original, no se pierde: queda enganchado en __cause__ gracias al
from e.
Eso es lo que en la pantalla te sale como The above exception was the direct cause of the following exception, con las dos trazas una encima de otra. Que parece que fallaron dos cosas y no: es la misma, contada dos veces, la de fuera con más información.
Si te olvidas del from e, Python engancha el original igual pero
con otra etiqueta: During handling of the above exception, another exception
occurred, que suena a "y encima falló otra cosa". Es la diferencia entre
"esto lo causó aquello" y "esto pasó mientras manejaba aquello", y la primera es
la verdad. Un carácter, dos lecturas distintas.
Un procesador de archivo que aguanta datos feos
filas = [
'C0045;Arequipa;480.37',
'C0325;Lima;524,90',
'C0260;Cusco;n/a',
'C0111;Piura',
'C0777;Trujillo;1200.00',
]
buenas = []
rechazadas = []
for numero, fila in enumerate(filas, start=1):
partes = fila.split(';')
if len(partes) != 3:
rechazadas.append((numero, fila, 'no tiene 3 campos'))
continue
codigo, ciudad, monto_texto = partes
try:
monto = float(monto_texto.replace(',', '.'))
except ValueError:
rechazadas.append((numero, fila, f'monto ilegible: {monto_texto}'))
continue
buenas.append({'codigo': codigo, 'ciudad': ciudad, 'monto': monto})
print(f'{len(buenas)} filas buenas, {len(rechazadas)} rechazadas')
print(f'Total: S/{sum(b["monto"] for b in buenas):,.2f}')
print()
for numero, fila, motivo in rechazadas:
print(f' fila {numero}: {motivo}')
3 filas buenas, 2 rechazadas Total: S/2,205.27 fila 3: monto ilegible: n/a fila 4: no tiene 3 campos
Eso es lo que quiero que te lleves de este capítulo. No es que no falle: es que cuando falla, te dice qué fila y por qué, y sigue con las demás.
Ese informe de rechazos es lo que le mandas a quien te pasó el archivo. Y es la diferencia entre "tu archivo tiene errores" y "las filas 3 y 4 tienen esto, ¿me las puedes corregir?" 🌸
Ejercicios
1. Reconoce el error
Provoca un ValueError y un
TypeError, y explica en qué se diferencian.
try: int('sin dato') # la celda que alguien dejo escrita a mano except ValueError as e: print('ValueError:', e) try: '12' + 5 # unidades como texto mas un numero except TypeError as e: print('TypeError:', e)
ValueError: invalid literal for int() with base 10: 'sin dato' TypeError: can only concatenate str (not "int") to str
En el primero int() sí acepta textos, este texto en particular no
servía. En el segundo la operación misma no existe entre esos tipos.
2. Convertir sin morir
Convierte una lista de textos a número, saltándote los que no se puedan, y cuenta cuántos se salvaron.
montos = ['100', '250.5', 'n/a', '', '1200'] # como llegan del sistema numeros = [] for m in montos: try: numeros.append(float(m)) except ValueError: pass print(numeros, f'{len(numeros)} de {len(montos)} montos legibles')
[100.0, 250.5, 1200.0] 3 de 5 montos legibles
Aquí el pass está bien porque la línea de abajo cuenta cuántos se
perdieron. El anti-patrón es el pass que no deja rastro de nada.
3. El except pelado, y por qué no
Escribe un except vacío que tape un error de
tipeo tuyo, para ver lo fácil que es.
montos = ['10', '20'] total = 0 for m in montos: try: total = total + flotar(m) # esta funcion no existe except: pass print('Total de la caja del día:', total)
Total de la caja del día: 0
Cero, sin una sola queja. El except pelado se tragó un
NameError que no tenía nada que ver con los datos. Con
except ValueError habría reventado y te habrías enterado.
4. Clave que puede no venir
Recorre una lista de ventas donde algunas no traen
canal y arma el conteo por canal sin que reviente.
ventas = [
{'ciudad': 'Lima', 'canal': 'Web'},
{'ciudad': 'Cusco'},
{'ciudad': 'Lima', 'canal': 'Web'},
]
conteo = {}
for v in ventas:
canal = v.get('canal', 'Sin canal')
conteo[canal] = conteo.get(canal, 0) + 1
print(conteo)
{'Web': 2, 'Sin canal': 1}
Aquí ni hizo falta el try: .get() con valor por
defecto lo resuelve más limpio. Cuando exista una forma de prevenir el error, es
mejor que atraparlo.
5. Tu propio raise
Escribe una función que rechace unidades negativas con un mensaje que explique qué hacer.
def registra_venta(unidades): if unidades < 0: raise ValueError( f'Las unidades no pueden ser negativas y llegaron {unidades}. ' f'Si es una devolución, va en el campo de notas de crédito.') return f'{unidades} unidades registradas' print(registra_venta(10)) registra_venta(-3)
ValueError: Las unidades no pueden ser negativas y llegaron -3. Si es una devolución, va en el campo de notas de crédito.
6. La división que a veces es cero
Calcula el promedio de una lista que puede venir vacía, de
dos formas: con try y con if.
def ticket_try(montos): try: return sum(montos) / len(montos) except ZeroDivisionError: return 0 def ticket_if(montos): if not montos: return 0 return sum(montos) / len(montos) # El filtro de una ciudad que ese dia no vendio nada print(ticket_try([]), ticket_if([])) print(ticket_try([10, 20]), ticket_if([10, 20]))
0 0 15.0 15.0
Las dos valen. La del if se lee mejor porque el caso vacío no es
excepcional, es normal.
7. Depurar con repr
Tienes dos códigos de cliente que parecen iguales y no lo son. Encuentra la diferencia.
a = 'C0045' b = 'C0045 ' print(a == b) print(repr(a), repr(b)) print(len(a), len(b))
False 'C0045' 'C0045 ' 5 6
Un espacio al final. Con print normal es invisible y te puede
costar una tarde de "pero si son iguales" 🙃
8. Contar los rechazos por motivo
Procesa una lista de filas y arma un conteo de rechazos agrupado por motivo.
montos = ['100', 'n/a', '', '250', 'abc'] # cinco filas del archivo motivos = {} for f in montos: if f == '': motivos['vacío'] = motivos.get('vacío', 0) + 1 continue try: float(f) except ValueError: motivos['no numérico'] = motivos.get('no numérico', 0) + 1 print(motivos)
{'no numérico': 2, 'vacío': 1}
Este conteo por motivo es lo primero que te van a pedir cuando digas "el archivo tiene problemas". Sin él, la conversación no avanza.
9. La traza de una columna que no vino
Provoca un KeyError a dos niveles de
profundidad y lee el mapa.
def ciudad_de(venta): return venta['ciudad'].strip().title() def ciudades_de(ventas): return [ciudad_de(v) for v in ventas] ventas = [{'ciudad': ' lima '}, {'ciudad': 'AREQUIPA'}, {'region': 'Cusco'}] try: ciudades_de(ventas) except KeyError as e: print('falto la clave:', e) for marco in traceback.extract_tb(e.__traceback__): print(f' linea {marco.lineno:>3} dentro de {marco.name}')
falto la clave: 'ciudad' linea 12 dentro de <module> linea 6 dentro de ciudades_de linea 6 dentro de <listcomp> linea 2 dentro de ciudad_de
Fíjate en lo que la traza te da y en lo que no 👀
Te dice que reventó dentro de ciudad_de, y eso está perfecto. No
te dice cuál de las tres ventas venía sin ciudad, que es
justamente lo que necesitas para llamar a quien te pasó el archivo.
Por eso existe el ejercicio siguiente.
10. Con from y sin from
Lanza el mismo error de las dos maneras y mira qué guarda Python en cada caso.
def con_from(texto): try: return float(texto) except ValueError as e: raise ValueError(f'monto ilegible: {texto!r}') from e def sin_from(texto): try: return float(texto) except ValueError: raise ValueError(f'monto ilegible: {texto!r}') for funcion in (con_from, sin_from): try: funcion('n/a') except ValueError as e: print(funcion.__name__) print(' causa declarada:', repr(e.__cause__)) print(' causa de fondo :', repr(e.__context__))
con_from
causa declarada: ValueError("could not convert string to float: 'n/a'")
causa de fondo : ValueError("could not convert string to float: 'n/a'")
sin_from
causa declarada: None
causa de fondo : ValueError("could not convert string to float: 'n/a'")
Los dos conservan el error original, así que no pierdes información en ninguno de los dos casos 🙂
La diferencia es que __cause__ solo se llena cuando tú lo
declaras. Es tu forma de decir "sé perfectamente por qué pasó esto y lo estoy
traduciendo", en vez de dejar que parezca un accidente encima de otro.
11. Un error con tu nombre y tus datos
Crea tu propio tipo de error, que además guarde el número de fila para poder usarlo después.
class MontoIlegible(ValueError): def __init__(self, fila, texto): self.fila = fila self.texto = texto super().__init__(f'fila {fila}: no puedo leer {texto!r} como monto') filas = ['480.37', 'n/a', '1200,00', ''] limpios, fallos = [], [] for numero, texto in enumerate(filas, start=1): try: try: limpios.append(float(texto.replace(',', '.'))) except ValueError as e: raise MontoIlegible(numero, texto) from e except MontoIlegible as e: fallos.append(e) print('leidos:', limpios) for e in fallos: print(' ', e, '| fila guardada aparte:', e.fila) print('y sigue siendo un ValueError:', isinstance(fallos[0], ValueError))
leidos: [480.37, 1200.0] fila 2: no puedo leer 'n/a' como monto | fila guardada aparte: 2 fila 4: no puedo leer '' como monto | fila guardada aparte: 4 y sigue siendo un ValueError: True
Dos cosas que hacen que esto valga la pena 🌸
Guarda datos, no solo texto. Ese e.fila es un
número de verdad, así que puedes contarlos, ordenarlos o escribirlos en un CSV de
rechazos. Un mensaje de error no se puede sumar.
Hereda de ValueError, no de Exception.
Por eso el último print sale True: quien tenga un
except ValueError escrito de antes lo sigue atrapando, y no le rompes
el código a nadie por añadir tu clase.
Y sí, el try dentro del try se ve raro. El de dentro
traduce y el de fuera recoge. En un programa de verdad el de fuera estaría en
otro sitio, seguramente en quien llama a la función.
12. Dejar rastro con logging
Sustituye los print de depuración por un
registro con niveles.
import io import logging registro = io.StringIO() log = logging.getLogger('ventas') log.handlers.clear() log.setLevel(logging.DEBUG) manejador = logging.StreamHandler(registro) manejador.setFormatter(logging.Formatter('%(levelname)-8s %(message)s')) log.addHandler(manejador) log.propagate = False for numero, texto in enumerate(filas, start=1): try: valor = float(texto.replace(',', '.')) except ValueError: log.warning('fila %s descartada: %r', numero, texto) else: log.debug('fila %s leida: %s', numero, valor) log.info('%s de %s filas leidas', len(limpios), len(filas)) print(registro.getvalue().rstrip())
DEBUG fila 1 leida: 480.37 WARNING fila 2 descartada: 'n/a' DEBUG fila 3 leida: 1200.0 WARNING fila 4 descartada: '' INFO 2 de 4 filas leidas
Un print siempre imprime. Un log imprime
si el nivel lo permite, y esa es toda la gracia 🎚️
Aquí lo puse en DEBUG, que es el más hablador, y por eso salen las
cinco líneas. Cámbialo a logging.WARNING y desaparecen los
debug: quedan solo los descartes. Sin tocar una sola línea del
bucle.
Eso es lo que quieres cuando el script corre solo todas las noches: hablador mientras lo pruebas, callado cuando ya funciona, y con las advertencias siempre visibles 🌙
Aquí escribí a memoria con io.StringIO() para que la salida se
vea en el libro. En tu máquina cambias esa línea por
logging.FileHandler('proceso.log') y tienes el registro en un
archivo que puedes leer al día siguiente.
Y el %s en vez de f-string no es capricho: con esa forma, si el
nivel apaga el mensaje, Python ni siquiera se molesta en armar el texto.
Cuando atrapar el error es peor que no atraparlo
Todo este capítulo va de no dejar que las cosas revienten en silencio. Así que toca mirar el otro lado: el try escrito con las mejores intenciones que termina restando dinero de un total 🕳️
La trampa
Atrapas el error concreto, como manda el capítulo, y devuelves None para que el programa no se caiga. Es lo prudente.
def a_numero(texto): try: return float(texto) except ValueError: return None montos = [a_numero(x) for x in columna] print(sum(m for m in montos if m))
Qué está mal
El programa no se cae, y eso es justo el problema 🕳️ Cada monto que no se pudo convertir desaparece de la suma sin dejar rastro. El total sale, se ve razonable, y le falta dinero.
Es el mismo desastre que errors='coerce' en pandas, pero escrito a mano: convertiste un error ruidoso en un número silencioso y equivocado. Y un total equivocado es mucho peor que un programa caído, porque el programa caído lo arregla alguien esa misma tarde.
Atrapar un error solo vale la pena si haces algo con él: contarlo, registrarlo, avisar. La regla que uso es que si no sabes qué hacer con el error, no lo atrapes. Que reviente y te obligue a decidir.
Comprueba que lo tienes
Envuelves cien líneas en un try con un except pelado y ya no falla nada. ¿Está bien?
- No: ahora los errores pasan igual y nadie se entera
- Sí, mientras el programa no se caiga
- Sí, si además imprimes un mensaje
- No, porque try es lento
Lo que te llevas
- 🎯 Atrapa el error concreto, nunca
excepta secas. - 📢 Nunca
passsin dejar rastro: cuenta, guarda o imprime. - 🛑 Un
raisetuyo, a tiempo, evita un reporte absurdo. - 🔍
repr()ytype()resuelven la mitad de los misterios. - 📋 Un buen proceso no es el que no falla, es el que dice qué fila falló y por qué.
Y si de todo el capítulo te llevas una sola frase, que sea esta:
Si no sabes qué hacer con un error, no lo atrapes. Que reviente y te obligue a decidir.
Y este capítulo importa más de lo que parece por lo que viene después: un total al que le faltan filas en silencio es exactamente lo que termina entrenando un modelo torcido, y de eso va el libro de machine learning 🤖
En el capítulo 8 salimos del cuaderno: módulos, import, y los
entornos virtuales, que son la respuesta a "en mi máquina funcionaba".
Que tengas lindo día! 🌸
Preguntas frecuentes
¿Cómo funciona try except en Python?
Lo que va en try se intenta; si revienta, se ejecuta el except en vez de cortar el programa. Conviene atrapar el error concreto y no todos, porque un except pelado se traga también los errores tuyos.
¿Qué es un traceback?
La lista de por dónde pasó el programa antes de reventar. Se lee de abajo hacia arriba: la última línea dice qué pasó y la penúltima dónde.
¿Qué significa TypeError en Python?
Que le pediste a un tipo de dato algo que no sabe hacer, como sumar un texto con un número. Suele venir de un CSV donde una columna vino como texto.
¿Qué significa KeyError?
Que pediste una clave que no está en el diccionario. Casi siempre es un nombre de columna con un espacio de más o en otra caja.
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.