Indice / Repaso: archivos y errores / 1.2
Excepciones: cuando el archivo miente
Un archivo de datos reales rompe el programa de tres maneras distintas, y solo una de ellas levanta una excepción. Esta lección enseña a manejar esa primera - y a no confundirla con las otras dos.
Para qué sirve
Todo lo que viene de afuera del programa puede fallar: el archivo no está, el
permiso no alcanza, la columna trae treinta donde esperabas un número.
Un programa que se cae con la fila 47 de 500 no procesó 453 filas buenas: las
perdió.
El objetivo no es que nunca falle nada. Es que cada falla quede localizada (sé qué fila y qué columna), clasificada (sé por qué) y contada (sé cuántas). Con eso, la lección 1.6 escribe el informe de rechazos, y la unidad 5 hace lo mismo con pandas.
ELI5Explicado facil
Atrapar excepciones se parece a manejar con el cinturón puesto: te salva del choque que ocurre. No te avisa de que el mapa está mal.
Un try/except solo reacciona a lo que Python decide que es un error.
El dato que dice NaN, o la nota -1: eso Python lo acepta
feliz. Validar es otra cosa, y hay que escribirla.
El ejemplo mínimo
from pathlib import Path
def leer(nombre):
"""Devuelve la cantidad de lineas, o None si el archivo no esta."""
print("--- intentando con", nombre)
try:
f = open(nombre, encoding="utf-8")
except FileNotFoundError:
# except: solo corre si esa excepcion ocurrio.
print(" except : no existe, sigo sin ese archivo")
return None
else:
# else: solo corre si el try NO fallo. Aca va el trabajo que
# depende del exito, y asi el try queda con una sola linea.
cuantas = sum(1 for _ in f)
print(" else :", cuantas, "lineas")
return cuantas
finally:
# finally: corre SIEMPRE. Con exito, con error, y tambien
# cuando el return ya se ejecuto.
print(" finally: cierro lo que haya que cerrar")
try:
f.close()
except NameError:
pass
leer("estudiantes_notas.csv")
leer("notas_del_anio_pasado.csv")
print()
print("El try envuelve UNA operacion riesgosa, no el programa entero:")
print("si adentro del try hubiera 20 lineas, el except no sabria cual fallo.")
--- intentando con estudiantes_notas.csv
else : 41 lineas
finally: cierro lo que haya que cerrar
--- intentando con notas_del_anio_pasado.csv
except : no existe, sigo sin ese archivo
finally: cierro lo que haya que cerrar
El try envuelve UNA operacion riesgosa, no el programa entero:
si adentro del try hubiera 20 lineas, el except no sabria cual fallo.
try envuelve una operación riesgosa, no el programa
entero: si adentro hubiera veinte líneas, el except no sabría cuál falló.
except corre solo si esa excepción ocurrió. else corre solo
si el try salió bien - es donde va el trabajo que depende del éxito,
y es lo que permite dejar el try con una sola línea.
finally corre siempre, incluso después de un return.
else y finally casi no se usan porque casi nadie se los
enseña, y son justamente los que evitan el try gigante.
Atrapar de menos, atrapar de más
# Las excepciones forman un arbol. Atrapar una clase atrapa a todas
# sus descendientes, y por eso importa cual nombras.
def cadena(clase):
"""La linea de herencia de una excepcion, de la mas concreta a Exception."""
nombres = []
for c in clase.__mro__:
nombres.append(c.__name__)
if c is Exception:
break
return " -> ".join(nombres)
print("FileNotFoundError:", cadena(FileNotFoundError))
print("PermissionError :", cadena(PermissionError))
print("ValueError :", cadena(ValueError))
print("KeyError :", cadena(KeyError))
print("IsADirectoryError:", cadena(IsADirectoryError))
print()
print("Por eso un solo except OSError cubre archivo inexistente y sin permiso:")
for nombre in ("no_existe.csv", "."):
try:
open(nombre, encoding="utf-8").read()
except OSError as error:
print(" ", nombre, "->", type(error).__name__)
print(" (en Linux la segunda da IsADirectoryError: tambien es un OSError)")
print()
print("Atrapar de mas es peor que no atrapar: aca hay un error de tipeo.")
fila = {"legajo": "L2601", "nota_1": "9.7"}
try:
valor = float(fila["nota_l"]) # ojo: ele minuscula, no uno
except Exception:
print(" except Exception -> 'dato invalido'")
print(" ...pero el dato estaba bien: la que fallo fue la clave del dict.")
try:
valor = float(fila["nota_l"])
except ValueError:
print(" nunca llega aca")
except KeyError as error:
print(" except KeyError ->", error, "| el mensaje ahora dice la verdad")
FileNotFoundError: FileNotFoundError -> OSError -> Exception
PermissionError : PermissionError -> OSError -> Exception
ValueError : ValueError -> Exception
KeyError : KeyError -> LookupError -> Exception
IsADirectoryError: IsADirectoryError -> OSError -> Exception
Por eso un solo except OSError cubre archivo inexistente y sin permiso:
no_existe.csv -> FileNotFoundError
. -> PermissionError
(en Linux la segunda da IsADirectoryError: tambien es un OSError)
Atrapar de mas es peor que no atrapar: aca hay un error de tipeo.
except Exception -> 'dato invalido'
...pero el dato estaba bien: la que fallo fue la clave del dict.
except KeyError -> 'nota_l' | el mensaje ahora dice la verdad
Las excepciones forman un árbol, y atrapar una clase atrapa a todas sus
descendientes. FileNotFoundError, PermissionError e
IsADirectoryError son todas OSError, así que un solo
except OSError cubre «no pude abrir el archivo» en cualquiera de sus
formas.
except Exception es casi siempre un error
En el ejemplo, la línea decía fila["nota_l"] con ele minúscula.
except Exception lo reporta como «dato inválido» y el programa sigue
tranquilo descartando el archivo entero, porque el error de tipeo
ocurre en todas las filas. Con except ValueError, el KeyError sube y te
enterás en la primera fila.
La regla práctica: nombrá la excepción que esperás. Si aparece otra, que rompa - un error del programa no es un dato sucio.
Lo que el try no ve
import csv
# Un try/except solo te protege de lo que efectivamente falla. Hay
# cadenas invalidas que float() acepta sin decir nada.
for texto in ("9.7", "-", "N/A", "", "NaN", "nan", "infinity", " 7 ", "7.0"):
try:
print(f"float({texto!r:>12}) = {float(texto)!r}")
except ValueError as error:
print(f"float({texto!r:>12}) -> ValueError: {error}")
print()
print("NaN e infinity pasan el try/except y contaminan todo lo que toquen:")
print(" 9.7 + float('NaN') =", 9.7 + float("NaN"))
print(" float('NaN') == float('NaN') ->", float("NaN") == float("NaN"))
print()
print("Asi se ve en un archivo real: la columna nota_3 de 40 estudiantes.")
with open("estudiantes_notas.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
notas = []
descartadas = 0
for fila in filas:
try:
notas.append(float(fila["nota_3"]))
except ValueError:
descartadas += 1
print(" filas:", len(filas), "| descartadas por ValueError:", descartadas)
print(" promedio:", sum(notas) / len(notas))
print(" ...el promedio es nan: el 'NaN' del archivo entro como numero valido.")
print()
# La validacion tiene que ser una decision explicita, no el efecto
# secundario de que float() se haya quejado o no.
NULOS = {"", "-", "N/A", "NaN", "null", "NA"}
buenas = []
for fila in filas:
crudo = fila["nota_3"].strip()
if crudo in NULOS:
continue
valor = float(crudo)
if not 0 <= valor <= 10:
continue
buenas.append(valor)
print(" con una lista explicita de nulos:", len(buenas), "notas validas")
print(" promedio:", round(sum(buenas) / len(buenas), 2))
print()
print("Los 6 valores problematicos de nota_3, en tres niveles de disimulo:")
print(" 4 hacen fallar a float() : '-' (x2), '' , 'N/A'")
print(" 1 pasa como numero valido : 'NaN' -> nan, y arruina el promedio")
print(" 1 pasa y es un numero real : -1.0, que no es una nota posible")
print("Solo el primer nivel lo detecta un try/except. Los otros dos hay que")
print("buscarlos: por eso validar no es lo mismo que atrapar excepciones.")
float( '9.7') = 9.7
float( '-') -> ValueError: could not convert string to float: '-'
float( 'N/A') -> ValueError: could not convert string to float: 'N/A'
float( '') -> ValueError: could not convert string to float: ''
float( 'NaN') = nan
float( 'nan') = nan
float( 'infinity') = inf
float( ' 7 ') = 7.0
float( '7.0') = 7.0
NaN e infinity pasan el try/except y contaminan todo lo que toquen:
9.7 + float('NaN') = nan
float('NaN') == float('NaN') -> False
Asi se ve en un archivo real: la columna nota_3 de 40 estudiantes.
filas: 40 | descartadas por ValueError: 4
promedio: nan
...el promedio es nan: el 'NaN' del archivo entro como numero valido.
con una lista explicita de nulos: 34 notas validas
promedio: 6.23
Los 6 valores problematicos de nota_3, en tres niveles de disimulo:
4 hacen fallar a float() : '-' (x2), '' , 'N/A'
1 pasa como numero valido : 'NaN' -> nan, y arruina el promedio
1 pasa y es un numero real : -1.0, que no es una nota posible
Solo el primer nivel lo detecta un try/except. Los otros dos hay que
buscarlos: por eso validar no es lo mismo que atrapar excepciones.
Esos seis valores de nota_3 son la razón de ser de esta unidad. Están
ordenados por cuánto se disimulan:
| Cuántos | Qué son | Quién los detecta |
|---|---|---|
| 4 | '-', '', 'N/A' | float() falla: un except ValueError alcanza |
| 1 | 'NaN' | Nadie. float("NaN") devuelve un número, y el promedio da nan |
| 1 | -1.0 | Nadie. Es un número real y perfectamente válido… para Python |
Los dos últimos hay que buscarlos a propósito: una lista explícita de tokens ausentes y un chequeo de rango. Esa es la diferencia entre manejar errores y validar datos, y la lección 1.5 la convierte en las cuatro capas.
Una excepción propia
import csv
import traceback
class FilaInvalida(Exception):
"""Una fila del archivo que no cumple las reglas del negocio.
Guarda el motivo con el vocabulario cerrado del curso, para que el
informe de rechazos se pueda contar y comparar.
"""
def __init__(self, motivo, columna, valor):
super().__init__(f"{motivo} en {columna}: {valor!r}")
self.motivo = motivo
self.columna = columna
self.valor = valor
NULOS = {"", "-", "N/A", "NaN", "null", "NA"}
def nota(fila, columna):
crudo = fila[columna].strip()
if crudo in NULOS:
raise FilaInvalida("ausente", columna, crudo)
try:
valor = float(crudo)
except ValueError as error:
# from error engancha la causa: el traceback muestra las dos.
raise FilaInvalida("no_numerico", columna, crudo) from error
if not 0 <= valor <= 10:
raise FilaInvalida("fuera_de_rango", columna, valor)
return valor
with open("estudiantes_notas.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
validas, rechazos = [], []
for fila in filas:
try:
notas = [nota(fila, c) for c in ("nota_1", "nota_2", "nota_3")]
except FilaInvalida as error:
rechazos.append((fila["legajo"], error.motivo, error.columna))
else:
validas.append((fila["legajo"], round(sum(notas) / 3, 2)))
print("validas:", len(validas), "| rechazadas:", len(rechazos))
print()
for legajo, motivo, columna in rechazos:
print(f" {legajo} {motivo:<15} {columna}")
print()
print("El motivo viaja DENTRO de la excepcion, no en el texto del mensaje:")
print("por eso se puede contar, ordenar y escribir a un CSV de rechazos.")
print()
print("Y asi se ve el encadenado de raise ... from:")
try:
nota({"nota_1": "N/A", "legajo": "L2613"}, "nota_1")
except FilaInvalida:
pass
try:
nota({"nota_1": "ocho", "legajo": "L9999"}, "nota_1")
except FilaInvalida:
for linea in traceback.format_exc().splitlines():
if linea.startswith(("ValueError", "FilaInvalida", "The above")):
print(" ", linea)
validas: 31 | rechazadas: 9
L2606 ausente nota_3
L2609 fuera_de_rango nota_1
L2613 ausente nota_3
L2618 fuera_de_rango nota_3
L2621 ausente nota_3
L2626 fuera_de_rango nota_2
L2629 ausente nota_3
L2634 fuera_de_rango nota_1
L2637 ausente nota_3
El motivo viaja DENTRO de la excepcion, no en el texto del mensaje:
por eso se puede contar, ordenar y escribir a un CSV de rechazos.
Y asi se ve el encadenado de raise ... from:
ValueError: could not convert string to float: 'ocho'
The above exception was the direct cause of the following exception:
FilaInvalida: no_numerico en nota_1: 'ocho'
La excepción propia sirve para una sola cosa, y es suficiente: el motivo viaja adentro del objeto, no dentro del texto del mensaje. Un motivo que es un atributo se puede contar, agrupar y escribir a un CSV de rechazos; uno que está pegado en una cadena, no.
raise ... from error
Convierte una excepción de bajo nivel en una del vocabulario de tu programa
sin perder la original. El traceback muestra las dos, separadas por
«The above exception was the direct cause of the following exception»: arriba
el ValueError técnico, abajo el FilaInvalida que dice qué
fila y qué columna.
Sin el from, la causa se pierde y el que depura ve el síntoma sin el
origen.
Acá hay que elegir
Con una fila inválida hay tres políticas, y el archivo no decide cuál corresponde: la decide el enunciado o quien pide el informe.
| Política | Sobre estudiantes_notas.csv | Cuándo |
|---|---|---|
| Descartar la fila entera | 31 promedios; se pierden 9 estudiantes, 5 de ellos solo por una nota faltante | Cuando un promedio incompleto sería mentira |
| Imputar lo que falta | 40 promedios, algunos calculados con un valor inventado | Cuando importa más no perder al estudiante que la exactitud de su nota |
| Informar y seguir | 31 promedios más un archivo de 9 rechazos: 5 ausente y 4 fuera_de_rango | Casi siempre: es descartar sin ocultar |
Los ejercicios EJ-1.3 y EJ-5.7 usan este mismo archivo con las dos primeras políticas, a propósito: mismos datos, resultados distintos, y ninguno de los dos está mal.
Dónde se rompe
Causa: la columna trae texto donde esperabas un número. En estos datasets es lo habitual, no la excepción.
Arreglo: try/except ValueError alrededor de
esa conversión, decidiendo qué hacer con la fila. No alrededor del
bucle entero.
Causa: el nombre de la columna no existe: un tipeo, un espacio de más en la cabecera, o el CSV cambió.
Arreglo: print(list(fila.keys())) en la primera vuelta. Y no
taparlo con except Exception.
Causa: float("NaN") no levanta nada: devuelve un número que
contamina toda suma en la que participe.
Arreglo: lista explícita de tokens ausentes antes de convertir, o
if valor != valor para detectar el nan después.
Causa: el finally usa una variable que se asignaba dentro
del try, y el try falló antes de asignarla.
Arreglo: usar with, que no necesita finally, o
inicializar la variable antes del try.
except: pass
Causa: el antipatrón. Descarta todas las filas, incluidas las que fallaban por un bug tuyo, y no deja rastro.
Arreglo: si de verdad hay que seguir, contá lo que descartaste y decilo al final. Un rechazo silencioso es un dato perdido que nadie va a echar de menos.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
except ValueError as error: | Atrapa y te deja el objeto | Conversión de texto a número o fecha |
except OSError: | Cubre inexistente, sin permiso y directorio | Al abrir un archivo |
except (ValueError, KeyError): | Dos clases, un solo bloque | Cuando la reacción es la misma |
else: | Corre si el try no falló | Para dejar el try en una línea |
finally: | Corre siempre | Liberar algo que with no maneja |
raise Propia(...) from error | Traduce sin perder la causa | Al convertir un error técnico en uno del negocio |
raise (a secas) | Vuelve a lanzar la que estabas manejando | Registrar y dejar subir |
type(error).__name__ | El nombre de la clase | Para contar por tipo |
class FilaInvalida(Exception) | Excepción propia con atributos | Cuando el motivo hay que contarlo después |
valor != valor | True solo para nan | Detectar el nan que se coló |