Saltar al contenido

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

Ejemplotry / except / else / finally, y en que orden corre cada uno
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.
Las cuatro partes, y para qué es cada una

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

EjemploLa jerarquia: atrapar de menos, atrapar de mas
# 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

EjemploEl dato invalido que NO levanta excepcion
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:

Tres niveles de dato inválido, en la misma columna
CuántosQué sonQuié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.0Nadie. 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

EjemploUna excepcion propia, y raise ... from para no perder la causa
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íticaSobre estudiantes_notas.csvCuándo
Descartar la fila entera31 promedios; se pierden 9 estudiantes, 5 de ellos solo por una nota faltanteCuando un promedio incompleto sería mentira
Imputar lo que falta40 promedios, algunos calculados con un valor inventadoCuando importa más no perder al estudiante que la exactitud de su nota
Informar y seguir31 promedios más un archivo de 9 rechazos: 5 ausente y 4 fuera_de_rangoCasi 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

ValueError: could not convert string to float: 'treinta'

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.

KeyError: 'nota_l'

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.

(sin error) el promedio da nan

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.

UnboundLocalError / NameError en el finally

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.

(sin error, sin salida, sin nada) - 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

Excepciones
ExpresiónQué haceCuándo
except ValueError as error:Atrapa y te deja el objetoConversión de texto a número o fecha
except OSError:Cubre inexistente, sin permiso y directorioAl abrir un archivo
except (ValueError, KeyError):Dos clases, un solo bloqueCuando la reacción es la misma
else:Corre si el try no fallóPara dejar el try en una línea
finally:Corre siempreLiberar algo que with no maneja
raise Propia(...) from errorTraduce sin perder la causaAl convertir un error técnico en uno del negocio
raise (a secas)Vuelve a lanzar la que estabas manejandoRegistrar y dejar subir
type(error).__name__El nombre de la clasePara contar por tipo
class FilaInvalida(Exception)Excepción propia con atributosCuando el motivo hay que contarlo después
valor != valorTrue solo para nanDetectar el nan que se coló

Ponete a prueba

1. ¿Cuándo se ejecuta el bloque else de un try?Nivel 1 · Recordar
2. Querés que un solo except cubra tanto «el archivo no existe» como «no tengo permiso». ¿Cuál nombrás?Nivel 1 · Recordar
3. El archivo trae NaN en una nota. ¿Qué hace float("NaN")?Nivel 2 · Predecir la salida
4. ¿Qué agrega raise FilaInvalida(...) from error respecto de raise FilaInvalida(...) a secas?Nivel 2 · Predecir la salida
5. Hay que procesar 500 filas convirtiendo fila["importe"] a float y saltear las que no se puedan. Las cuatro corren: ¿cuál conviene?Nivel 3 · Elegir entre dos casi correctas
6. ¿Por qué except: pass es peor que dejar que el programa se caiga?Nivel 3 · Elegir entre dos casi correctas
7. Este código recorre las 40 filas de estudiantes_notas.csv, no imprime ningún error, y el promedio general da nan. ¿Por qué?
notas = []
for fila in filas:
    try:
        notas.append(float(fila["nota_3"]))
    except ValueError:
        pass
print(sum(notas) / len(notas))
Nivel 4 · Encontrar el defecto