Archivos y manejo de excepciones

"El programa que asume que todo siempre va a funcionar también asume que el universo coopera. El universo, históricamente, no coopera."

Qué vas a aprender en este capítulo

Hasta ahora tus programas perdían todo al cerrarse. En este capítulo aprendés a persistir datos en archivos: leer, escribir, manejar formatos comunes (JSON, CSV). También aprendés a manejar errores con try/except: porque archivos faltantes, permisos denegados, datos corruptos y disco lleno son la norma, no la excepción. Al final vas a tener todas las piezas para que tu programa hable con el mundo y no se caiga al primer problema.

4.1 La idea: la memoria que persiste

💡 Intuición

Imaginate que estás escribiendo en una hoja, y cada vez que cerrás los ojos, la hoja se borra. Esa es la memoria de tu programa: existe mientras corre. Al cerrarse, todo desaparece.

Para que un programa recuerde algo entre ejecuciones, hay que escribir a un archivo (o base de datos, o servicio de red — más adelante). El SO se encarga de que esos bytes sigan en el disco mañana.

Un archivo es un cubo de bytes con un nombre, persistente. Tu programa puede abrirlo, leer, escribir y cerrarlo. Las syscalls correspondientes son open, read, write, close — pero Python las envuelve en una API limpia.

Y hablando de errores: lo que distingue programas profesionales de programas escolares es cómo manejan lo inesperado. ¿Y si el archivo no existe? ¿Y si el JSON está malformado? ¿Y si se llenó el disco? Un programa serio anticipa y maneja. Un programa de juguete explota.

4.2 Abrir y cerrar archivos

📐 Fundamento

Forma básica:

f = open("archivo.txt", "r")
contenido = f.read()
f.close()

open() devuelve un objeto archivo. f.read() lee TODO el contenido. f.close() libera el recurso.

Modos:

Modo Para qué
"r" Lectura (default). Falla si no existe.
"w" Escritura. Trunca (borra contenido) si existe; crea si no.
"a" Append. Escribe al final.
"x" Crear. Falla si existe.
"r+" Lectura/escritura, no trunca.
"b" Modo binario (combiná: "rb", "wb").
"t" Modo texto (default).

encoding="utf-8" es buena costumbre — evita sorpresas con caracteres especiales:

f = open("archivo.txt", "r", encoding="utf-8")

El problema del olvido

Si tu programa crashea entre open y close, el archivo queda abierto y posiblemente sin escribirse. Eso es bug.

Solución idiomática: with (context manager).

with open("archivo.txt", "r") as f:
    contenido = f.read()
# acá afuera, f ya está cerrado, GARANTIZADO

with garantiza que f.close() se llame al salir del bloque, incluso si hay excepción. Es la forma moderna y siempre la preferida.

4.3 Leer archivos

📐 Fundamento

Tres formas principales de leer:

1. Todo de una vez

with open("archivo.txt") as f:
    texto = f.read()      # devuelve un string completo

OK para archivos chicos. Mal para archivos grandes (carga todo a RAM).

2. Línea por línea

with open("archivo.txt") as f:
    for linea in f:
        print(linea.rstrip())   # rstrip quita el \n

Eficiente. Procesa una línea, descarta, sigue. Funciona para archivos enormes.

3. readlines (devuelve lista de líneas)

with open("archivo.txt") as f:
    lineas = f.readlines()    # ["linea1\n", "linea2\n", ...]

OK si necesités acceso aleatorio por índice.

Path

Aceptar paths absolutos o relativos. Buenas prácticas modernas usan pathlib:

from pathlib import Path

p = Path("datos") / "archivo.txt"
texto = p.read_text(encoding="utf-8")

Path es portable (Windows/Linux/Mac), tiene operadores cómodos (/ para concatenar), y métodos como .read_text(), .write_text(), .exists(), .glob().

4.4 Escribir archivos

📐 Fundamento

with open("salida.txt", "w") as f:
    f.write("Hola\n")
    f.write("Mundo\n")

f.write no agrega \n automáticamente (a diferencia de print).

Escribir varias líneas con print redirigido:

with open("salida.txt", "w") as f:
    print("Hola", file=f)
    print("Mundo", file=f)

Más cómodo si querés print normal.

Append (agregar sin borrar):

with open("log.txt", "a") as f:
    f.write(f"{fecha} - usuario inició sesión\n")

Modo binario. Para imágenes, ZIPs, archivos no-texto:

with open("foto.jpg", "rb") as f:
    bytes_imagen = f.read()

with open("copia.jpg", "wb") as f:
    f.write(bytes_imagen)

Modo binario te da bytes, no str. Para conversión: bytes.decode("utf-8") y str.encode("utf-8").

4.5 Formatos comunes: CSV

🛠️ En la práctica

CSV (Comma-Separated Values) es el formato más común para datos tabulares.

Leer:

import csv

with open("estudiantes.csv", encoding="utf-8") as f:
    reader = csv.reader(f)
    encabezado = next(reader)        # primera fila
    for fila in reader:
        print(fila)                   # ['Ana', '19', '7.5']

Mejor con DictReader:

with open("estudiantes.csv", encoding="utf-8") as f:
    for fila in csv.DictReader(f):
        print(fila["nombre"], fila["edad"])

Lee el encabezado automáticamente y devuelve cada fila como dict.

Escribir:

import csv

with open("salida.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(["nombre", "edad", "promedio"])
    writer.writerow(["Ana", 19, 7.5])
    writer.writerow(["Beto", 20, 8.0])

newline="" es crucial en Windows — sin él los archivos quedan con dobles saltos de línea.

Excel y separadores. Excel en español usa ; como separador, no ,. Cargar CSV "español":

csv.reader(f, delimiter=";")

4.6 Formatos comunes: JSON

📐 Fundamento

JSON (JavaScript Object Notation) es el formato estándar para datos estructurados, web APIs, configuración.

Leer:

import json

with open("datos.json", encoding="utf-8") as f:
    datos = json.load(f)

print(datos)   # un dict o lista, según el archivo

Escribir:

datos = {
    "estudiantes": ["ana", "beto"],
    "promedios": {"ana": 8.5, "beto": 7.0},
}

with open("datos.json", "w", encoding="utf-8") as f:
    json.dump(datos, f, indent=2, ensure_ascii=False)

indent=2 formatea bonito. ensure_ascii=False permite tildes y eñes.

Conversión Python → JSON:

Python JSON
dict object
list, tuple array
str string
int, float number
True, False true, false
None null

De string (no archivo):

texto = '{"nombre": "ana", "edad": 19}'
datos = json.loads(texto)        # carga desde string
texto = json.dumps(datos, indent=2)   # serializa a string

JSON no soporta: datetime, set, función, NaN. Para esos hay extensiones o serialización custom.

4.7 Excepciones — cuando todo falla

📐 Fundamento

Una excepción es un evento anormal: archivo no existe, división por cero, índice fuera de rango, RAM agotada. Si no la manejás, tu programa muere (con un traceback).

Sintaxis:

try:
    f = open("inexistente.txt")
    contenido = f.read()
except FileNotFoundError:
    print("Archivo no existe")

try ejecuta el código riesgoso. Si dentro se levanta una excepción del tipo capturado, salta al except correspondiente.

Capturar varios tipos:

try:
    n = int(input("Número: "))
    r = 100 / n
except ValueError:
    print("No es un número")
except ZeroDivisionError:
    print("No se puede dividir por 0")

Capturar todos (con cuidado):

try:
    # algo
except Exception as e:
    print(f"Error: {e}")

Exception captura casi todas (no KeyboardInterrupt ni SystemExit, que son señales). as e te da el objeto para inspección.

else y finally

Forma completa:

try:
    # código riesgoso
except SomeError:
    # manejo
else:
    # ejecuta si NO hubo excepción
finally:
    # SIEMPRE ejecuta (con o sin excepción)

finally es para liberar recursos que tomaste. Es lo que hace with por dentro:

f = open("archivo.txt")
try:
    contenido = f.read()
finally:
    f.close()        # cierra incluso si hay error

else se usa menos. Va donde el código exitoso "principal" sin estorbar el try.

Jerarquía

Las excepciones forman jerarquía. FileNotFoundError hereda de OSError que hereda de Exception que hereda de BaseException.

BaseException
 └── Exception
      ├── ValueError
      ├── TypeError
      ├── KeyError
      ├── IndexError
      ├── ArithmeticError
      │    └── ZeroDivisionError
      ├── OSError
      │    ├── FileNotFoundError
      │    ├── PermissionError
      │    └── ...
      └── ...

Capturar la superclase captura todas las subclases. Por eso except Exception atrapa casi todo.

Lo más específico arriba:

try:
    ...
except FileNotFoundError:
    ...        # más específico
except OSError:
    ...        # más general
except Exception:
    ...        # último recurso

4.8 Levantar excepciones

📐 Fundamento

Tu propio código puede levantar errores con raise:

def dividir(a, b):
    if b == 0:
        raise ValueError("Divisor no puede ser cero")
    return a / b

Definir tus propias:

class StockInsuficiente(Exception):
    """Stock no alcanza para el pedido."""
    def __init__(self, producto, pedido, disponible):
        self.producto = producto
        self.pedido = pedido
        self.disponible = disponible
        super().__init__(f"{producto}: pedidas {pedido}, disponibles {disponible}")


def vender(producto, cantidad, inventario):
    if inventario.get(producto, 0) < cantidad:
        raise StockInsuficiente(producto, cantidad, inventario.get(producto, 0))
    inventario[producto] -= cantidad
try:
    vender("queso", 100, {"queso": 5})
except StockInsuficiente as e:
    print(f"Lo siento: {e}")
    print(f"  Disponible: {e.disponible}")

Por qué crear excepciones propias:

  1. Diferenciar errores de tu dominio del resto. Un ValueError genérico es ambiguo; un StockInsuficiente es claro.
  2. Llevar info estructurada. Atributos del objeto excepción.
  3. Manejo selectivo. Distintos handlers para distintos errores.

4.9 Patrón "EAFP" vs "LBYL"

📐 Fundamento

Dos estilos para manejar errores:

LBYL (Look Before You Leap) — chequear antes:

import os
if os.path.exists("archivo.txt"):
    with open("archivo.txt") as f:
        contenido = f.read()
else:
    contenido = ""

EAFP (Easier to Ask Forgiveness than Permission) — intentar y atrapar:

try:
    with open("archivo.txt") as f:
        contenido = f.read()
except FileNotFoundError:
    contenido = ""

Python prefiere EAFP por:

  1. Race conditions. Entre el check y el uso, el archivo puede aparecer/desaparecer. EAFP no tiene ese problema.
  2. Más rápido en el caso feliz (la verificación tiene costo siempre, aunque rara vez falle).
  3. Más legible — el código del happy path no se contamina con chequeos.

Cuándo LBYL: chequeos baratos antes de operaciones caras. Por ejemplo, validar input del usuario antes de mandarlo a la base de datos.

4.10 Proyecto: persistir el menú

🏗️ Avance del proyecto

Hasta ahora la pupusería pierde todo cuando cierra el programa. Vamos a persistir el menú y los pedidos en archivos JSON.

# pupuseria_v4.py - persistencia con JSON

import json
from pathlib import Path
from datetime import datetime

ARCHIVO_MENU = Path("menu.json")
ARCHIVO_PEDIDOS = Path("pedidos.json")
IVA = 0.13


# ---------------------- excepciones propias ----------------------

class CodigoInvalido(Exception):
    pass

class StockInsuficiente(Exception):
    def __init__(self, codigo, pedido, disponible):
        super().__init__(f"{codigo}: pedidas {pedido}, hay {disponible}")
        self.codigo, self.pedido, self.disponible = codigo, pedido, disponible


# ---------------------- carga y guardado ----------------------

def cargar_menu():
    """Carga el menú desde archivo. Si no existe, devuelve uno default."""
    try:
        return json.loads(ARCHIVO_MENU.read_text(encoding="utf-8"))
    except FileNotFoundError:
        # Primera vez — crear default
        default = {
            "Q":  {"nombre": "Pupusa de queso",      "precio": 0.50, "stock": 100},
            "R":  {"nombre": "Pupusa revuelta",      "precio": 0.60, "stock": 80},
            "F":  {"nombre": "Pupusa de frijol",     "precio": 0.55, "stock": 60},
        }
        guardar_menu(default)
        return default
    except json.JSONDecodeError as e:
        raise RuntimeError(f"menu.json está corrupto: {e}")


def guardar_menu(menu):
    ARCHIVO_MENU.write_text(
        json.dumps(menu, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


def registrar_pedido(pedido, total):
    """Append-only del pedido a pedidos.json."""
    historial = []
    try:
        historial = json.loads(ARCHIVO_PEDIDOS.read_text(encoding="utf-8"))
    except FileNotFoundError:
        pass    # archivo nuevo
    except json.JSONDecodeError:
        pass    # corrupto, ignorar (en producción habría que loguear)

    historial.append({
        "fecha": datetime.now().isoformat(timespec="seconds"),
        "items": pedido,
        "total": round(total, 2),
    })
    ARCHIVO_PEDIDOS.write_text(
        json.dumps(historial, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )


# ---------------------- lógica ----------------------

def vender(menu, codigo, cantidad):
    if codigo not in menu:
        raise CodigoInvalido(codigo)
    p = menu[codigo]
    if p["stock"] < cantidad:
        raise StockInsuficiente(codigo, cantidad, p["stock"])
    p["stock"] -= cantidad
    return cantidad * p["precio"]


def imprimir_menu(menu):
    print("\n=== Menú ===")
    for codigo, p in menu.items():
        print(f"  [{codigo:3}] {p['nombre']:25} ${p['precio']:.2f}   stock: {p['stock']}")
    print()


def main():
    menu = cargar_menu()
    imprimir_menu(menu)

    pedido = {}
    subtotal = 0
    while True:
        s = input("Código (o 'fin'): ").strip().upper()
        if s == "FIN":
            break
        cant_s = input("  Cantidad: ").strip()
        try:
            cantidad = int(cant_s)
            importe = vender(menu, s, cantidad)
        except CodigoInvalido as e:
            print(f"  ✗ Código '{e.args[0]}' no existe")
            continue
        except StockInsuficiente as e:
            print(f"  ✗ Stock insuficiente: {e}")
            continue
        except ValueError:
            print("  ✗ Cantidad inválida")
            continue
        pedido[s] = pedido.get(s, 0) + cantidad
        subtotal += importe
        print(f"  ✓ Acumulado: ${subtotal:.2f}")

    if not pedido:
        print("Sin pedido.")
        return

    iva = subtotal * IVA
    total = subtotal + iva
    print(f"\n=== TOTAL ===")
    print(f"  Subtotal: ${subtotal:.2f}")
    print(f"  IVA:      ${iva:.2f}")
    print(f"  Total:    ${total:.2f}")

    try:
        guardar_menu(menu)
        registrar_pedido(pedido, total)
        print("✓ Guardado")
    except OSError as e:
        print(f"✗ No pude guardar: {e}")


if __name__ == "__main__":
    main()

Lo nuevo:

  1. Persistencia con JSON. El menú se carga al inicio, se guarda al final.
  2. Historial append-only de pedidos en pedidos.json.
  3. Excepciones propias (CodigoInvalido, StockInsuficiente) en lugar de strings de error.
  4. Manejo robusto — archivo corrupto, archivo faltante, error de I/O — todo capturado.
  5. pathlib para manejo de rutas.

Si corrés el programa, lo cerrás y lo abrís otra vez, el inventario y los precios siguen donde los dejaste. Y pedidos.json tiene la historia completa. Eso es un programa real.

Próximo capítulo: lo refactorizamos con clases (Producto, Pupuseria, Pedido) para que el código sea más limpio y extensible.

4.11 Resumen visual

Concepto Una línea
open(path, mode) Abre archivo. Modos: r, w, a, b
with open(...) as f: Cierre automático garantizado
f.read(), f.readlines(), for line in f Tres formas de leer
f.write(...) Escribir (no agrega \n)
pathlib.Path API moderna de paths
csv.reader, csv.DictReader Leer CSV
json.load, json.dump Cargar/guardar JSON
try/except/else/finally Manejo de excepciones
raise SomeError(...) Levantar excepción
class MiError(Exception) Definir excepción propia
EAFP Estilo Python: probar y capturar

4.12 Ejercicios

✏️ Ejercicio 4.1 — Contar líneas y palabras

Escribí un programa que reciba un nombre de archivo y muestre cuántas líneas y cuántas palabras tiene.

✏️ Ejercicio 4.2 — Conversor CSV → JSON

Recibí un archivo CSV con encabezado y devolvé un JSON con la lista de filas como dicts.

✏️ Ejercicio 4.3 — Validador robusto

Pedile al usuario una nota (entre 0 y 10). Validá hasta que escriba algo válido. Manejá: cadena vacía, no es número, fuera de rango.

✏️ Ejercicio 4.4 — Buscar en logs

Escribí función que recorra /var/log/syslog (o cualquier archivo grande) y cuente cuántas líneas contienen una palabra dada, sin cargar el archivo entero a memoria.

4.13 Para profundizar


Definiciones nuevas: archivo, modo de apertura, context manager, with, encoding, pathlib, CSV, JSON, excepción, try/except/else/finally, raise, EAFP, LBYL, jerarquía de excepciones, excepción personalizada.