Tres lecciones de persistencia han dejado la carpeta de Papyrus hecha un bazar: registro_altas.txt, catalogo.csv, ventas.csv, catalogo.json, socios.json, config.json… todo amontonado junto a los .py. Y hay tareas pendientes que no son de contenido sino de gestión: crear carpetas, comprobar qué existe, listar los informes de un mes, renombrar en lote, copiar el catálogo a una copia de seguridad con la fecha en el nombre. De todo eso se ocupa esta lección, con pathlib.Path como protagonista — la forma moderna y orientada a objetos de trabajar con rutas —, el módulo os que el módulo 3 dejó prometido (hoy entenderás por qué lo aplazamos), y shutil para copiar y mover. Al final, Papyrus tendrá una estructura de proyecto profesional y una copia de seguridad nocturna automática. El módulo cierra donde empezó: Ana apaga el ordenador — y ya no pierde nada.

Contenido

  1. Rutas: el dato que parece cadena pero no lo es
  2. pathlib.Path: rutas orientadas a objetos
  3. Rutas relativas vs absolutas y Path.cwd()
  4. Anatomía de una ruta: name, stem, suffix, parent
  5. Preguntar y crear: exists(), is_file(), is_dir(), mkdir()
  6. Buscar archivos con glob()
  7. os y os.path: el legado (tabla de equivalencias)
  8. shutil: copiar, mover y borrar árboles
  9. La estructura definitiva del proyecto papyrus/
  10. Copia de seguridad nocturna con fecha
  11. Renombrado en lote: los informes diarios
  12. Errores comunes y consejos
  13. Ejercicios con soluciones

Rutas: el dato que parece cadena pero no lo es

Hasta ahora hemos pasado a open() cadenas como "catalogo.json", y para el archivo suelto junto al script bastaba. Pero una ruta real tiene estructura (carpetas, nombre, extensión), operaciones propias (unir, subir un nivel, cambiar la extensión) y un problema incómodo: el separador cambia según el sistema\ en Windows, / en macOS/Linux. Manipular rutas concatenando cadenas (carpeta + "\\" + nombre) produce código frágil y no portable. Python tiene desde siempre os.path para esto (funciones sobre cadenas) y, desde hace años, algo mejor: tratar la ruta como un objeto con métodos — exactamente la filosofía del módulo 5.

pathlib.Path: rutas orientadas a objetos

Path (del módulo pathlib, biblioteca estándar) ya hizo un cameo en 06-01 con exists(); ahora le toca el papel principal:

from pathlib import Path

datos = Path("datos")                     # una ruta relativa (aún no toca el disco)
catalogo = datos / "catalogo.json"        # el operador / une rutas — ¡en cualquier sistema!
print(catalogo)                           # datos\catalogo.json (Windows) · datos/catalogo.json (macOS/Linux)

La estrella es el operador /: Path sobrecarga la división (un método mágico más, __truediv__ — 05-05 en acción en la biblioteca estándar) para unir tramos de ruta con el separador correcto de cada sistema. Nunca más "datos" + "\\" + "catalogo.json".

Crear un Path no crea nada en disco: es un valor que describe una ruta, exista o no. Y encaja con todo lo aprendido: open() (y por tanto csv y json) acepta objetos Path donde antes pasábamos cadenas — incluso tiene atajos que ya conoces implícitamente:

with open(catalogo, "r", encoding="utf-8") as f:    # un Path donde iba una cadena: idéntico
    ...

texto = catalogo.read_text(encoding="utf-8")        # atajo: lee todo (equivale a open+read)
catalogo.write_text(texto, encoding="utf-8")        # atajo: escribe todo (open "w"+write)

Los atajos read_text/write_text son cómodos para archivos pequeños de una sola operación; para todo lo demás, el with open(...) de 06-01 sigue siendo el patrón.

Rutas relativas vs absolutas y Path.cwd()

  • Una ruta absoluta parte de la raíz del sistema: C:\tiendas\papyrus\datos\catalogo.json. Inequívoca, pero atada a una máquina concreta.
  • Una ruta relativa (datos/catalogo.json) se resuelve desde el directorio de trabajo actual (current working directory), que consultas con Path.cwd().
print(Path.cwd())                        # C:\tiendas\papyrus  (desde dónde ejecutaste python)
print((Path("datos") / "catalogo.json").resolve())   # la versión absoluta de la relativa

La trampa clásica: el directorio de trabajo es desde donde ejecutas, no donde está el script. Si ejecutas python papyrus\caja.py desde C:\tiendas, las rutas relativas del programa se resuelven desde C:\tiendas — y de repente "no encuentra" un datos/ que sí existe, pero un nivel más abajo. El antídoto profesional es anclar las rutas al propio archivo con la variable __file__ (prima de la __name__ de 03-04):

BASE = Path(__file__).parent             # la carpeta donde vive ESTE script
DATOS = BASE / "datos"                   # siempre correcta, se ejecute desde donde se ejecute

Este par de líneas encabezará los programas de Papyrus a partir de ahora.

Anatomía de una ruta: name, stem, suffix, parent

Un Path se deja diseccionar con atributos de solo lectura:

ruta = Path("datos") / "copias" / "catalogo-2026-07-13.json"

print(ruta.name)      # catalogo-2026-07-13.json   (nombre completo)
print(ruta.stem)      # catalogo-2026-07-13        (nombre sin extensión)
print(ruta.suffix)    # .json                      (la extensión, con punto)
print(ruta.parent)    # datos\copias               (la carpeta contenedora, otro Path)

Y para derivar rutas nuevas sin tocar cadenas: ruta.with_name("otro.json") cambia el nombre completo y ruta.with_suffix(".csv") solo la extensión. Estas cuatro piezas — stem, suffix, with_name, glob — son el kit completo del renombrado en lote que harás al final.

Preguntar y crear: exists(), is_file(), is_dir(), mkdir()

DATOS = Path("datos")

print(DATOS.exists())                    # ¿hay algo en esa ruta?
print(DATOS.is_dir())                    # ¿y es una carpeta?
print((DATOS / "catalogo.json").is_file())   # ¿es un archivo?

DATOS.mkdir(parents=True, exist_ok=True)     # crea la carpeta — la línea canónica

Los dos argumentos de mkdir() merecen párrafo propio, porque sin ellos el método es quisquilloso:

  • parents=True: crea también las carpetas intermedias que falten (datos/copias/2026 de una vez). Sin él, si falta un tramo intermedio: FileNotFoundError.
  • exist_ok=True: no protesta si la carpeta ya existe. Sin él, segunda ejecución del programa: FileExistsError.

Juntos hacen mkdir idempotente — puedes llamarlo mil veces con el mismo resultado —, ideal para el arranque de un programa: "asegúrate de que mis carpetas existen". Y sí: exists() es el paliativo que venimos usando desde 06-01 para archivos que quizá no estén; sigue siendo un apaño con grietas, y el módulo 7 (ya inminente) trae la herramienta de verdad.

Buscar archivos con glob()

glob(patron) devuelve los archivos de una carpeta que encajan con un patrón de comodines — * significa "cualquier cosa":

INFORMES = Path("informes")

for ruta in sorted(INFORMES.glob("*.csv")):        # todos los CSV de informes/
    print(ruta.name)

julio = sorted(INFORMES.glob("informe-2026-07-*.csv"))   # solo los de julio de 2026
print(len(julio), "informes este mes")

Detalles de uso: devuelve objetos Path (con todos sus atributos listos), en orden no garantizado — de ahí el sorted() —, y con rglob(patron) la búsqueda desciende recursivamente por las subcarpetas. Fíjate en el dividendo de nombrar con fechas ISO (yyyy-mm-dd, como las LineaVenta de 05-06): los nombres ordenan cronológicamente por sí solos y los patrones por mes o por año salen gratis.

os y os.path: el legado (tabla de equivalencias)

El módulo 3 mencionó os («interacción con el sistema operativo») y prometió desarrollarlo aquí. Cumplido — con matiz: para rutas y archivos, os/os.path son la API veterana, anterior a pathlib, basada en funciones que reciben y devuelven cadenas. La encontrarás en millones de líneas de código existente y en respuestas antiguas de internet, así que debes saber leerla; para escribir código nuevo, este curso usa pathlib.

Tarea os / os.path (legado) pathlib (moderno)
Unir rutas os.path.join("datos", "catalogo.json") Path("datos") / "catalogo.json"
¿Existe? os.path.exists(ruta) ruta.exists()
¿Es archivo / carpeta? os.path.isfile(r) / os.path.isdir(r) r.is_file() / r.is_dir()
Crear carpetas os.makedirs(r, exist_ok=True) r.mkdir(parents=True, exist_ok=True)
Nombre / extensión os.path.basename(r) / os.path.splitext(r)[1] r.name / r.suffix
Carpeta contenedora os.path.dirname(r) r.parent
Directorio de trabajo os.getcwd() Path.cwd()
Listar contenido os.listdir(r) (nombres, cadenas) r.iterdir() / r.glob("*") (objetos Path)
Renombrar/mover os.rename(a, b) a.rename(b)
Borrar un archivo os.remove(r) r.unlink()

La diferencia de fondo es la del módulo 5: os.path son funciones sueltas operando sobre cadenas pasivas; pathlib empaqueta datos y comportamiento en un objeto. La misma evolución que vivió el catálogo. (os sigue siendo insustituible para otras cosas — variables de entorno con os.environ, procesos — que quedan fuera de este curso de momento.)

shutil: copiar, mover y borrar árboles

pathlib no copia archivos; para eso está shutil (shell utilities), su compañero natural:

import shutil

shutil.copy2("datos/catalogo.json", "copias/catalogo.json")   # copia archivo (con metadatos)
shutil.move("informe.csv", "informes/informe.csv")            # mueve (o renombra entre carpetas)
shutil.copytree("datos", "datos_espejo")                      # copia una carpeta ENTERA
shutil.rmtree("datos_espejo")                                 # ⚠ borra un árbol COMPLETO
  • copy2() copia el archivo conservando metadatos como la fecha de modificación (su hermana copy() no los conserva; para copias de seguridad, copy2).
  • move() sirve tanto para mover entre carpetas como para renombrar.
  • rmtree() merece letras rojas: borra la carpeta y todo su contenido, recursivamente, sin papelera de reciclaje y sin confirmación. Un rmtree sobre la ruta equivocada es irrecuperable. Regla de oro: jamás sobre una ruta construida con datos del usuario, y siempre tras un is_dir() sobre una ruta que puedas leer en voz alta sin sudar.

La estructura definitiva del proyecto papyrus/

Con carpetas, rutas ancladas y mkdir idempotente, ordenamos por fin el bazar:

papyrus/
├── caja.py                  ← el programa principal
├── ficha_libro.py           ← altas (06-01)
├── papyrus_utils.py         ← el módulo propio de 03-04
├── config.json              ← configuración (06-03), junto al código
├── datos/                   ← el estado vivo de la tienda
│   ├── catalogo.json        ← el catálogo canónico (06-03)
│   ├── ventas.csv           ← histórico de ventas (06-02)
│   ├── socios.json          ← socios y compras (06-03)
│   └── registro_altas.txt   ← diario de altas (06-01)
├── copias/                  ← copias de seguridad fechadas
└── informes/                ← informes diarios de caja (06-02)

Y el arranque que la garantiza, listo para encabezar caja.py:

from pathlib import Path

BASE = Path(__file__).parent            # anclado al script, no al cwd
DATOS = BASE / "datos"
COPIAS = BASE / "copias"
INFORMES = BASE / "informes"

for carpeta in (DATOS, COPIAS, INFORMES):
    carpeta.mkdir(parents=True, exist_ok=True)      # idempotente: mil ejecuciones, un resultado

RUTA_CATALOGO = DATOS / "catalogo.json"
RUTA_VENTAS = DATOS / "ventas.csv"
RUTA_CONFIG = BASE / "config.json"

A partir de aquí, cargar_catalogo_json(RUTA_CATALOGO) y compañía (06-02/06-03) reciben objetos Path y todo funciona igual — pero ordenado y portable.

Copia de seguridad nocturna con fecha

Ana ya no pierde el catálogo al apagar… pero ¿y si un día se guarda un catálogo corrupto encima del bueno? Un solo archivo es un solo punto de fallo. La solución clásica: al cierre de caja, copiar el catálogo a copias/ con la fecha en el nombredatetime (03-05) fabrica el nombre y shutil.copy2 hace el trabajo:

import shutil
from datetime import date
from pathlib import Path

def copia_de_seguridad(ruta_catalogo, carpeta_copias):
    """Copia fechada del catálogo. Devuelve la ruta de la copia, o None si no hay qué copiar."""
    if not ruta_catalogo.exists():                   # paliativo conocido; módulo 7 lo jubilará
        return None
    carpeta_copias.mkdir(parents=True, exist_ok=True)
    destino = carpeta_copias / f"{ruta_catalogo.stem}-{date.today()}{ruta_catalogo.suffix}"
    shutil.copy2(ruta_catalogo, destino)
    return destino

print(copia_de_seguridad(RUTA_CATALOGO, COPIAS))     # copias\catalogo-2026-07-13.json

Disecciona la línea del destino: stem (catalogo) + fecha ISO + suffix (.json) = catalogo-2026-07-13.json. Cada noche, un archivo nuevo; ninguna copia pisa a la anterior; y sorted(COPIAS.glob("catalogo-*.json"))[-1] — glob + orden ISO — devuelve siempre la copia más reciente. Tres lecciones de este módulo colaborando en una expresión.

Renombrado en lote: los informes diarios

Último encargo de Ana. Antes de automatizar la caja, los informes se guardaban a mano como informe-13-07-2026.csv — fecha "a la europea", que ordenada alfabéticamente mezcla los meses (el 13-07 queda antes que el 02-08... y después que el 01-01 de cualquier año). Hay docenas. Renombrarlos a mano, ni hablar: glob + stem + rename lo hacen en lote:

from pathlib import Path

def normalizar_informes(carpeta):
    """informe-dd-mm-aaaa.csv → informe-aaaa-mm-dd.csv (orden cronológico gratis)."""
    for ruta in carpeta.glob("informe-??-??-????.csv"):    # ? = exactamente un carácter
        _, dia, mes, anyo = ruta.stem.split("-")           # el split de siempre (04-05)
        nueva = ruta.with_name(f"informe-{anyo}-{mes}-{dia}{ruta.suffix}")
        if nueva.exists():                                 # no pisar: rename sobrescribe sin avisar
            print(f"AVISO: {nueva.name} ya existe; no toco {ruta.name}")
            continue
        ruta.rename(nueva)
        print(f"{ruta.name}  →  {nueva.name}")

normalizar_informes(INFORMES)
# informe-13-07-2026.csv  →  informe-2026-07-13.csv
# informe-01-07-2026.csv  →  informe-2026-07-01.csv

El patrón informe-??-??-????.csv (cada ? casa un único carácter) evita capturar los informes ya normalizados — que tienen el año, de cuatro cifras, justo después del guion. Es la receta general del renombrado en lote: glob para seleccionar, stem/split para desmontar el nombre, with_name para montarlo, exists para no pisar, rename para ejecutar. Memorízala: la usarás con fotos, descargas y logs el resto de tu vida.

Errores Comunes y Consejos

  • Concatenar rutas como cadenas (carpeta + "/" + nombre): frágil y no portable. El operador / de Path existe para esto y elige el separador correcto en cada sistema.
  • Confiar en el directorio de trabajo: "en mi terminal funcionaba" es el síntoma. Las rutas relativas dependen de desde dónde se ejecuta; ancla con BASE = Path(__file__).parent.
  • mkdir() pelado: sin parents=True falla si falta un tramo; sin exist_ok=True falla a la segunda ejecución. La forma canónica lleva ambos, casi siempre.
  • Olvidar que glob() no ordena: el orden depende del sistema de archivos. Si el orden importa — y con nombres fechados casi siempre importa — envuelve en sorted().
  • rename() hacia un nombre que ya existe: en algunos sistemas sobrescribe silenciosamente el destino. Comprueba exists() antes, como en normalizar_informes().
  • rmtree() alegre: borra árboles enteros sin papelera. Verifica la ruta (imprímela antes en una ejecución de prueba), nunca la construyas con entrada del usuario, y pregúntate si move() a una carpeta papelera/ propia no es mejor política.
  • Rutas de Windows en cadenas con \: "datos\nuevo.txt" esconde un \n (¡salto de línea!, 01-03). Si escribes rutas literales, usa / — Windows también lo acepta — o mejor, constrúyelas con Path y el operador /.
  • Consejo: nombra los archivos fechados siempre en ISO (aaaa-mm-dd). Orden alfabético = orden cronológico, los glob por mes salen solos y te ahorras el renombrado en lote que acabas de tener que hacer.

Ejercicios

Ejercicio 1: inventario del proyecto

Escribe inventario(base) que reciba la ruta de papyrus/ y recorra datos/, copias/ e informes/ imprimiendo, por carpeta, cada archivo con su extensión y su tamaño en bytes (ruta.stat().st_size), y al final el total de archivos. Usa glob("*") e is_file() para ignorar posibles subcarpetas. Extra: agrupa los totales por suffix con un Counter (04-06).

Ejercicio 2: retención de copias

Las copias nocturnas se acumulan sin límite. Escribe limpiar_copias(carpeta, conservar=7) que liste las catalogo-*.json de copias/, las ordene por nombre (fecha ISO: el orden alfabético es cronológico) y borre con unlink() todas menos las conservar más recientes, imprimiendo cada borrado. Pruébala creando a mano diez copias falsas con write_text().

Ejercicio 3: cierre de caja completo

Junta el módulo entero en cierre_de_caja(base): (1) asegura las carpetas con la línea canónica de mkdir; (2) guarda el catálogo con guardar_catalogo_json() (06-03) en datos/catalogo.json; (3) haz la copia fechada con copia_de_seguridad(); (4) genera informes/informe-<hoy>.csv con informe_caja() (06-02) leyendo datos/ventas.csv; y (5) imprime un resumen con los nombres de los archivos creados. Ejecútala dos veces seguidas y comprueba que no falla ni duplica nada raro.

Soluciones

Solución 1:

from collections import Counter
from pathlib import Path

def inventario(base):
    total = 0
    por_tipo = Counter()
    for carpeta in (base / "datos", base / "copias", base / "informes"):
        print(f"\n{carpeta.name}/")
        for ruta in sorted(carpeta.glob("*")):
            if ruta.is_file():                       # ignora subcarpetas
                print(f"  {ruta.name:<32} {ruta.suffix:<6} {ruta.stat().st_size} bytes")
                por_tipo[ruta.suffix] += 1
                total += 1
    print(f"\nTotal: {total} archivos — {dict(por_tipo)}")

inventario(BASE)

stat() da acceso a los metadatos del archivo (tamaño, fechas); st_size es el tamaño en bytes.

Solución 2:

def limpiar_copias(carpeta, conservar=7):
    copias = sorted(carpeta.glob("catalogo-*.json"))   # ISO ⇒ orden alfabético = cronológico
    for ruta in copias[:-conservar]:                   # todas menos las N últimas (04-01)
        ruta.unlink()
        print(f"Borrada copia antigua: {ruta.name}")

# banco de pruebas: diez copias falsas
for dia in range(1, 11):
    (COPIAS / f"catalogo-2026-07-{dia:02d}.json").write_text("{}", encoding="utf-8")
limpiar_copias(COPIAS)     # borra de la 01 a la 03; quedan las 7 más recientes

El slicing negativo de 04-01 hace la política de retención en una línea. Fíjate en {dia:02d}: sin el cero de relleno (catalogo-2026-07-1.json), el orden alfabético dejaría de ser cronológico y la limpieza borraría las copias equivocadas.

Solución 3:

from datetime import date

def cierre_de_caja(base):
    datos, copias, informes = base / "datos", base / "copias", base / "informes"
    for carpeta in (datos, copias, informes):
        carpeta.mkdir(parents=True, exist_ok=True)               # (1) idempotente

    ruta_catalogo = datos / "catalogo.json"
    guardar_catalogo_json(catalogo, ruta_catalogo)               # (2) 06-03
    copia = copia_de_seguridad(ruta_catalogo, copias)            # (3) esta lección

    ruta_informe = informes / f"informe-{date.today()}.csv"
    informe_caja(str(date.today()), datos / "ventas.csv", ruta_informe)   # (4) 06-02

    print(f"Cierre {date.today()}: catálogo guardado, copia {copia.name}, "
          f"informe {ruta_informe.name}")                        # (5)

cierre_de_caja(BASE)

Segunda ejecución en el mismo día: las carpetas ya existen (exist_ok), el catálogo y la copia se regeneran con el mismo nombre y el informe se reescribe — todo estable. Este cierre_de_caja() es, en miniatura, el módulo 6 entero funcionando como una sola pieza.

Conclusión

El módulo 6 salda la deuda que dejó abierta el cierre del módulo 5: el estado de Papyrus ya no muere con el proceso. En 06-01, open() con sus modos, el with que garantiza el cierre y el encoding="utf-8" innegociable dieron a la tienda su primera memoria — registro_altas.txt cumplió la promesa que ficha_libro.py arrastraba desde el módulo 1; el CSV convirtió el catálogo y las ventas en tablas que Excel y la distribuidora entienden, con DictReader/DictWriter y el round-trip dict[str, Libro]catalogo.csvdict[str, Libro] pagando el peaje strfloat en un único lugar (06-02); JSON aportó tipos que sobreviven al viaje, anidamiento para los socios con sus compras, el puente asdict()/Libro(**datos) y un config.json que saca las constantes del código (06-03); y pathlib ordenó el bazar en datos/, copias/ e informes/, con rutas ancladas a __file__, glob para encontrar, shutil.copy2 para la copia nocturna fechada y el renombrado en lote como receta general (06-04). Ana apaga el ordenador y, por primera vez desde el módulo 1, no pierde nada. Pero fíjate en cuántas veces este módulo dijo "paliativo": Path.exists() antes de leer, "no edites mal el JSON", "reza para que el CSV traiga números". ¿Y si el archivo no está, si la copia está corrupta, si float(fila["precio"]) recibe "gratis", si Júlia teclea un precio imposible? Hoy, cualquiera de esas cosas detiene el programa con un error a media pantalla. El módulo 7 enseña a Papyrus a encajar los golpes: excepciones — detectarlas, manejarlas con try/except, lanzarlas con intención y hasta diseñar las tuyas. Tus objetos ya saben pensar y recordar; ahora aprenderán a equivocarse con elegancia.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados