Módulo 2 · Lección 26 de 28
Proyecto: organizador de ficheros
Una herramienta que ordena la carpeta de descargas por tipo de fichero, con modo de simulación, recursividad y resumen en tabla.
Abre tu carpeta de descargas. Si se parece a la media, ahí dentro conviven facturas en PDF, capturas de pantalla, instaladores que ya no sirven y algún vídeo que pesa dos gigas. Ordenarlo a mano lleva veinte minutos y se vuelve a desordenar en una semana.
Este es el primero de los tres proyectos que cierran el módulo, y el más sencillo de los tres. Junta argparse y pathlib en una herramienta que se usa de verdad, y sirve para asentar un patrón que vas a repetir el resto de tu vida como programador: separar el decidir del hacer.
Cómo se va a usar#
Antes de escribir una línea, conviene tener clarísimo qué se teclea y qué se ve. La herramienta terminada se usa así:
python3 -m organizador ~/Descargas --simular
python3 -m organizador ~/Descargas
python3 -m organizador ~/Descargas --recursivoLa primera orden no toca nada: enseña el plan. La segunda lo ejecuta. La tercera baja también por las subcarpetas.
Y esto es lo que deja detrás:
Descargas/
comprimidos/
documentos/
imagenes/
otros/
videos/Solo eso. Cinco carpetas y ningún fichero suelto.
El diseño#
Tres ficheros con código y dos de fontanería:
organizador/
__init__.py Marca la carpeta como paquete
categorias.py Qué extensión va a qué categoría
logica.py Decide qué se mueve y lo mueve
cli.py Argumentos, mensajes y códigos de salida
__main__.py Permite python3 -m organizador| Fichero | De qué se encarga | ¿Imprime? |
|---|---|---|
categorias.py | Los datos: el mapa de extensiones | No |
logica.py | Listar, planificar, renombrar, mover | No |
cli.py | Leer argumentos, pintar tablas, decidir el código de salida | Sí, solo él |
La columna de la derecha es la regla entera. En logica.py no hay ni un print, igual que en el gestor de tareas del módulo anterior las clases no imprimían. Esa separación, la misma de la lección de estructura de un proyecto, aquí tiene un premio inmediato y concreto: el modo --simular sale gratis. Lo verás en cuanto lleguemos a él.
Parte 1: el mapa de categorías#
Empezamos por los datos, que es la parte que más se toca y la que menos código tiene.
"""Las categorías del organizador y las extensiones que van a cada una."""
from pathlib import Path
CATEGORIAS: dict[str, set[str]] = {
"imagenes": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".bmp", ".svg"},
"documentos": {
".pdf", ".txt", ".md", ".doc", ".docx", ".odt",
".xls", ".xlsx", ".ods", ".csv", ".ppt", ".pptx",
},
"videos": {".mp4", ".mkv", ".avi", ".mov", ".webm", ".mpg", ".mpeg"},
"comprimidos": {".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".rar", ".7z"},
}
CATEGORIA_POR_DEFECTO = "otros"
# Índice al revés: de extensión a categoría. Se construye una vez, al importar.
CATEGORIA_DE_EXTENSION: dict[str, str] = {
extension: categoria
for categoria, extensiones in CATEGORIAS.items()
for extension in extensiones
}
NOMBRES_DE_CARPETA: set[str] = set(CATEGORIAS) | {CATEGORIA_POR_DEFECTO}
def categoria_de(fichero: Path) -> str:
"""Devuelve la categoría que le toca a un fichero por su extensión."""
return CATEGORIA_DE_EXTENSION.get(fichero.suffix.lower(), CATEGORIA_POR_DEFECTO)Probándolo:
from pathlib import Path
from organizador.categorias import CATEGORIA_DE_EXTENSION, categoria_de
print(categoria_de(Path("foto.JPG")))
print(categoria_de(Path("contrato.pdf")))
print(categoria_de(Path("instalador.dmg")))
print(categoria_de(Path("leeme")))
print(len(CATEGORIA_DE_EXTENSION), CATEGORIA_DE_EXTENSION[".mp4"])imagenes
documentos
otros
otros
35 videosLas decisiones de este fichero:
- El diccionario va de categoría a extensiones, no al revés. Escrito así se lee y se amplía de un vistazo: añadir el
.avifa las imágenes es tocar una línea. El diccionario que de verdad necesita el programa es el contrario, de extensión a categoría, y por eso se construye con una comprensión anidada en el momento de importar. Se escribe cómodo y se consulta rápido. - La comprensión tiene dos
for. El primero recorre las parejas del diccionario y el segundo, las extensiones de cada categoría. Se leen en el mismo orden en que se escribirían dos bucles anidados. suffix.lower(). Una captura de pantalla de Windows se llamaCaptura.PNG, con la extensión en mayúsculas, y.PNGno está en ningún conjunto. Bajar a minúsculas antes de buscar evita tener que duplicar cada extensión..get()con valor por defecto en vez de unif extension in .... Lo que no conoce el programa cae enotros, y esa es exactamente la semántica deget.NOMBRES_DE_CARPETAexiste por una razón que se entiende en la parte siguiente: el programa tiene que saber reconocer las carpetas que él mismo crea para no volver a meterse en ellas.- Un fichero sin extensión devuelve cadena vacía en
suffix, que tampoco está en el mapa, así que va aotrossin tratamiento especial.
Parte 2: qué ficheros hay que mover#
Ahora la lógica. Primero, listar lo que hay que tocar, que tiene más miga de la que parece.
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from .categorias import NOMBRES_DE_CARPETA, categoria_de
def ficheros_a_ordenar(carpeta: Path, recursivo: bool = False) -> list[Path]:
"""Devuelve los ficheros de la carpeta que hay que clasificar, ya ordenados."""
patron = "**/*" if recursivo else "*"
encontrados = []
for elemento in carpeta.glob(patron):
if not elemento.is_file() or elemento.name.startswith("."):
continue
if elemento.relative_to(carpeta).parts[0] in NOMBRES_DE_CARPETA:
continue
encontrados.append(elemento)
return sorted(encontrados)Son doce líneas con cuatro decisiones dentro:
- El patrón cambia según
recursivo.glob("*")mira un solo nivel yglob("**/*")baja por todo el árbol, que es lo mismo que hacerglob("*"). Tener la diferencia en una sola variable evita duplicar el bucle. is_file()descarta las carpetas, porqueglobdevuelve las dos cosas.- Los ficheros ocultos se saltan. En macOS y Linux, todo lo que empieza por punto es configuración:
.DS_Store,.gitignore,.env. Mover esos ficheros rompe cosas. - Y la más importante: se ignora lo que ya está dentro de una carpeta de categoría. Sin ese
if, la segunda ejecución con--recursivovolvería a encontrarimagenes/foto.jpg, calcularía que le toca ir aimagenes/y se pondría a mover ficheros encima de sí mismos.relative_to(carpeta).parts[0]es el primer tramo de la ruta relativa, es decir, la subcarpeta de primer nivel donde vive el fichero.
Con la carpeta de ejemplo que usaremos en toda la lección:
from pathlib import Path
from organizador.logica import ficheros_a_ordenar
print([f.name for f in ficheros_a_ordenar(Path("descargas"))])['apuntes.txt', 'boda.mp4', 'captura.PNG', 'clip.mov', 'contrato.pdf', 'copia-seguridad.zip', 'foto-playa.jpg', 'instalador.dmg', 'logo.svg', 'notas.xyz', 'presentacion.pptx', 'proyecto.tar.gz', 'ventas-2026.csv']El sorted() del final no es decorativo. glob entrega los ficheros en el orden que le dé el sistema de ficheros, que ni es alfabético ni es el mismo en dos ordenadores. Al ordenar, la salida del programa es predecible y comparable entre ejecuciones, que es justo lo que quieres cuando alguien te mande una captura preguntando por qué ha pasado algo.
Parte 3: las colisiones de nombre#
Dos ficheros distintos pueden llamarse igual. Pasa constantemente: el navegador ya numera las descargas repetidas, pero en cuanto juntas los ficheros de dos subcarpetas, aparecen dos contrato.pdf que no tienen nada que ver.
Mover el segundo encima del primero es perder datos en silencio, y Path.rename() hace exactamente eso en macOS y Linux: sobrescribe sin preguntar.
def nombre_libre(destino: Path, reservados: set[Path]) -> Path:
"""Devuelve el destino, o una variante con sufijo numérico si ya está ocupado."""
candidato = destino
contador = 1
while candidato.exists() or candidato in reservados:
candidato = destino.with_name(f"{destino.stem}-{contador}{destino.suffix}")
contador += 1
return candidatofrom pathlib import Path
from organizador.logica import nombre_libre
carpeta = Path("descargas")
print(nombre_libre(carpeta / "imagenes" / "foto-playa.jpg", set()))
print(nombre_libre(carpeta / "imagenes" / "nueva.png", set()))
print(nombre_libre(carpeta / "imagenes" / "nueva.png", {carpeta / "imagenes" / "nueva.png"}))descargas/imagenes/foto-playa-1.jpg
descargas/imagenes/nueva.png
descargas/imagenes/nueva-1.pngEl stem y el suffix de pathlib hacen el trabajo sucio: foto-playa.jpg se convierte en foto-playa-1.jpg y no en foto-playa.jpg-1, que es lo que saldría concatenando cadenas a mano. Y with_name() construye la ruta hermana sin tocar la carpeta.
Fíjate en el segundo parámetro, reservados, y en la tercera llamada del ejemplo. Ese conjunto es la parte que se olvida siempre. El bucle while pregunta por exists(), que consulta el disco, pero los ficheros del plan todavía no están en el disco. Si dos ficheros distintos de la lista van a acabar llamándose igual, exists() dirá que no a los dos y el segundo machacará al primero en cuanto se ejecute el plan. Apuntando cada destino elegido en reservados se cierra el agujero.
Parte 4: el plan y su ejecución#
Aquí está el corazón del diseño, y son veinte líneas:
@dataclass
class Movimiento:
"""Un fichero y el sitio al que le toca ir."""
origen: Path
destino: Path
categoria: str
@property
def renombrado(self) -> bool:
"""True si hubo que cambiarle el nombre para evitar una colisión."""
return self.origen.name != self.destino.name
def planificar(carpeta: Path, recursivo: bool = False) -> list[Movimiento]:
"""Construye la lista de movimientos sin tocar el disco."""
movimientos = []
reservados: set[Path] = set()
for fichero in ficheros_a_ordenar(carpeta, recursivo):
categoria = categoria_de(fichero)
destino = nombre_libre(carpeta / categoria / fichero.name, reservados)
reservados.add(destino)
movimientos.append(Movimiento(fichero, destino, categoria))
return movimientos
def ejecutar(movimientos: list[Movimiento]) -> tuple[list[Movimiento], list[tuple[Movimiento, OSError]]]:
"""Realiza los movimientos. Devuelve los hechos y los fallidos con su error."""
hechos: list[Movimiento] = []
fallidos: list[tuple[Movimiento, OSError]] = []
for movimiento in movimientos:
try:
movimiento.destino.parent.mkdir(parents=True, exist_ok=True)
movimiento.origen.rename(movimiento.destino)
except OSError as error:
fallidos.append((movimiento, error))
else:
hechos.append(movimiento)
return hechos, fallidos
def contar_por_categoria(movimientos: list[Movimiento]) -> Counter:
"""Cuántos ficheros hay en cada categoría."""
return Counter(movimiento.categoria for movimiento in movimientos)from pathlib import Path
from organizador.logica import contar_por_categoria, planificar
plan = planificar(Path("descargas"), recursivo=True)
print(len(plan))
print(contar_por_categoria(plan))
print(plan[6])16
Counter({'documentos': 6, 'imagenes': 4, 'videos': 2, 'comprimidos': 2, 'otros': 2})
Movimiento(origen=PosixPath('descargas/facturas/contrato.pdf'), destino=PosixPath('descargas/documentos/contrato-1.pdf'), categoria='documentos')Las decisiones:
planificarno toca el disco yejecutarno decide nada. El primero devuelve una lista de objetos; el segundo la recorre y obedece. Son dos funciones porque son dos momentos distintos, y entre uno y otro se puede enseñar el plan, filtrarlo, guardarlo en un registro o tirarlo a la basura.Movimientoes una dataclass y no una tupla.movimiento.destinose lee;movimiento[1], no. Y el__repr__que regala la dataclass hace que depurar el plan sea imprimirlo, como se ve arriba.renombradoes una propiedad, no un campo, porque es un dato deducido de los otros dos. Guardarlo sería duplicar información que puede quedar desincronizada.mkdirva dentro del bucle, no antes. Así solo se crean las carpetas de las categorías que de verdad tienen ficheros: una carpeta de descargas sin vídeos no acaba con una carpetavideosvacía. Y conexist_ok=Truellamarlo trece veces no cuesta nada.ejecutaratrapaOSErrorfichero a fichero y sigue. Un permiso denegado en el séptimo fichero no puede tirar el programa dejando el trabajo a medias y sin decir cuánto se hizo. Se apunta y se continúa.- El
elsedeltry. El cuerpo delelsese ejecuta solo si no hubo excepción, y deja clarísimo quehechos.appendes lo que pasa en el caso bueno. Podría ir al final deltry, pero entonces habría dos líneas dentro deltryy solo una puede fallar. Countersobre una expresión generadora resuelve el recuento en una línea, tal como viste en la lección de la biblioteca estándar.
El apartado importante: por qué --simular#
Toda herramienta que mueva, sobrescriba o borre ficheros tiene que poder decirte qué va a hacer antes de hacerlo. Sin excepciones.
El motivo no es la elegancia, es que tú no puedes probar el programa de otra manera. Una herramienta que solo tiene un modo se prueba ejecutándola, y ejecutarla es justo lo que da miedo. Con un modo de simulación, la prueba es gratis: la ejecutas sobre tu carpeta real, lees las veinte líneas del plan, ves que la tercera está mal, arreglas el programa y vuelves a ejecutar. Sin simulación ese ciclo cuesta una copia de seguridad cada vuelta, así que en la práctica no se hace, y lo que no se prueba no funciona.
Hay más razones:
- Las extensiones sorprenden. Descubrirás que tu
.heicdel móvil no estaba en la lista, que los.tar.gzcuentan como.gz, o que tenías cuarenta ficheros sin extensión. Mejor descubrirlo leyendo que buscando dónde han ido a parar. - La confianza se construye. La primera vez se usa con
--simular, la décima ya no. Un programa en el que no confías es un programa que no usas. - Deshacer es carísimo. Una vez movidos trece mil ficheros, volver atrás requiere un programa nuevo. Leer trece mil líneas de plan antes es incómodo, pero es posible.
Lo bonito es cuánto cuesta implementarlo aquí: nada. Como planificar ya está separada de ejecutar, el modo simulación es no llamar a la segunda.
movimientos = planificar(carpeta, opciones.recursivo)
if opciones.simular:
mostrar_plan(movimientos, carpeta)
return 0
hechos, fallidos = ejecutar(movimientos)Ese es el premio del diseño de la parte anterior. Cuando una herramienta mezcla decidir y hacer, añadirle un modo de simulación después obliga a meter un if not simular: alrededor de cada operación peligrosa, y basta que se escape uno para que el modo seguro no sea seguro. Con las dos fases separadas, el modo seguro es imposible de romper: no hay ninguna llamada a rename que pueda ejecutarse por accidente, porque la fase que las hace ni siquiera se llama.
Parte 5: la interfaz#
Tres argumentos y ninguna sorpresa:
def construir_analizador() -> argparse.ArgumentParser:
analizador = argparse.ArgumentParser(
prog="organizador",
description="Ordena una carpeta moviendo cada fichero a una subcarpeta según su extensión.",
epilog="Prueba siempre primero con --simular.",
)
analizador.add_argument(
"carpeta",
type=Path,
help="carpeta que hay que ordenar",
)
analizador.add_argument(
"-s", "--simular",
action="store_true",
help="enseña lo que haría, pero no mueve nada",
)
analizador.add_argument(
"-r", "--recursivo",
action="store_true",
help="ordena también los ficheros de las subcarpetas",
)
return analizadorpython3 -m organizador --helpusage: organizador [-h] [-s] [-r] carpeta
Ordena una carpeta moviendo cada fichero a una subcarpeta según su extensión.
positional arguments:
carpeta carpeta que hay que ordenar
options:
-h, --help show this help message and exit
-s, --simular enseña lo que haría, pero no mueve nada
-r, --recursivo ordena también los ficheros de las subcarpetas
Prueba siempre primero con --simular.type=Pathhace queopciones.carpetallegue ya como objetoPathy no como cadena. Es el mismo mecanismo quetype=int: cualquier función de un argumento sirve.- La carpeta es un posicional y no una opción porque es obligatoria y solo hay una. Lo que no se puede omitir va sin guiones.
action="store_true"convierte las dos opciones en banderas: están o no están, sin valor detrás.prog="organizador"fija el nombre que sale en la ayuda. Sin él, al ejecutar conpython3 -msaldría__main__.py, que no le dice nada a nadie.epiloges el sitio para el consejo que quieres que lea quien pida ayuda.
Parte 6: el resumen en tabla#
Trece líneas de "esto va allí" no dicen gran cosa. El recuento por categoría, sí:
ANCHO_NOMBRE = 34
def plural(cantidad: int, singular: str, varios: str) -> str:
"""Elige la forma que toca para que los mensajes no canten."""
return singular if cantidad == 1 else varios
def mostrar_plan(movimientos: list[Movimiento], carpeta: Path) -> None:
"""Enseña, fichero a fichero, a dónde va cada uno."""
for movimiento in movimientos:
origen = movimiento.origen.relative_to(carpeta)
destino = movimiento.destino.relative_to(carpeta)
aviso = " (renombrado)" if movimiento.renombrado else ""
print(f" {str(origen):<{ANCHO_NOMBRE}} -> {destino}{aviso}")
def mostrar_resumen(movimientos: list[Movimiento]) -> None:
"""Pinta la tabla con el recuento por categoría."""
recuento = contar_por_categoria(movimientos)
orden = list(CATEGORIAS) + [CATEGORIA_POR_DEFECTO]
print()
print(f" {'CATEGORÍA':<14}{'FICHEROS':>9}")
print(" " + "-" * 23)
for categoria in orden:
if recuento[categoria]:
print(f" {categoria:<14}{recuento[categoria]:>9}")
print(" " + "-" * 23)
print(f" {'TOTAL':<14}{len(movimientos):>9}") CATEGORÍA FICHEROS
-----------------------
imagenes 3
documentos 4
videos 2
comprimidos 2
otros 2
-----------------------
TOTAL 13Las decisiones:
- Las rutas se enseñan relativas a la carpeta. Con
relative_to(carpeta), en pantalla salefacturas/contrato.pdfen vez de/Users/erwin/Descargas/facturas/contrato.pdfrepetido dieciséis veces. La información que importa es la que cambia de línea a línea. - El orden de la tabla sale del diccionario
CATEGORIAS, no del recuento. Así las categorías salen siempre en el mismo orden, yotrosqueda al final porque es el cajón de sastre. Los diccionarios de Python conservan el orden de inserción desde la versión 3.7, y aquí eso se usa como característica. - Las categorías con cero se omiten con el
if recuento[categoria]. Una fila que ponevideos 0es ruido. Counterdevuelve0para las claves que no tiene en vez de lanzarKeyError, que es lo que permite escribir eseiftan corto.- La función
pluralexiste porque "1 ficheros movidos" canta muchísimo. Tres líneas para que los mensajes estén bien escritos son tres líneas bien gastadas.
Parte 7: códigos de salida y errores#
Un programa de terminal habla con el sistema por dos canales que no se ven en pantalla: el código de salida y stderr.
def main(argumentos: list[str] | None = None) -> int:
opciones = construir_analizador().parse_args(argumentos)
carpeta: Path = opciones.carpeta
if not carpeta.exists():
print(f"organizador: la carpeta '{carpeta}' no existe.", file=sys.stderr)
return 1
if not carpeta.is_dir():
print(f"organizador: '{carpeta}' no es una carpeta.", file=sys.stderr)
return 1
...El reparto que usa este programa, que es el habitual:
| Código | Cuándo |
|---|---|
0 | Todo bien, aunque no hubiera nada que mover |
1 | El programa se ejecutó pero algo salió mal: la carpeta no existe, o falló algún movimiento |
2 | Los argumentos estaban mal. Lo pone argparse él solo |
maindevuelve un número, no llama asys.exit. Quien decide terminar el proceso es__main__.py, consys.exit(main()). Asímainse puede llamar desde una prueba automática, pasarle una lista de argumentos y mirar qué devuelve sin que el intérprete se cierre a mitad de la suite.- El parámetro
argumentospor defecto valeNone, yparse_args(None)leesys.argv. Es decir: en producción no se le pasa nada y funciona solo; en una prueba se le pasa["descargas", "--simular"]. - Los errores van a
stderry llevan el nombre del programa delante. Eso es lo que permitepython3 -m organizador descargas 2> errores.txt: la tabla sale por pantalla y los problemas se quedan en el fichero. El prefijoorganizador:es la convención de toda la terminal, y se agradece cuando el mensaje aparece en medio de la salida de otras diez herramientas. - "No había nada que ordenar" devuelve 0. No es un fallo. Confundir "no hay trabajo" con "ha ido mal" hace que los guiones que usen tu herramienta se paren sin motivo.
El programa completo#
"""Las categorías del organizador y las extensiones que van a cada una."""
from pathlib import Path
CATEGORIAS: dict[str, set[str]] = {
"imagenes": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".bmp", ".svg"},
"documentos": {
".pdf", ".txt", ".md", ".doc", ".docx", ".odt",
".xls", ".xlsx", ".ods", ".csv", ".ppt", ".pptx",
},
"videos": {".mp4", ".mkv", ".avi", ".mov", ".webm", ".mpg", ".mpeg"},
"comprimidos": {".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".rar", ".7z"},
}
CATEGORIA_POR_DEFECTO = "otros"
# Índice al revés: de extensión a categoría. Se construye una vez, al importar.
CATEGORIA_DE_EXTENSION: dict[str, str] = {
extension: categoria
for categoria, extensiones in CATEGORIAS.items()
for extension in extensiones
}
NOMBRES_DE_CARPETA: set[str] = set(CATEGORIAS) | {CATEGORIA_POR_DEFECTO}
def categoria_de(fichero: Path) -> str:
"""Devuelve la categoría que le toca a un fichero por su extensión."""
return CATEGORIA_DE_EXTENSION.get(fichero.suffix.lower(), CATEGORIA_POR_DEFECTO)"""La lógica del organizador: decidir qué se mueve y moverlo. No imprime nada."""
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from .categorias import NOMBRES_DE_CARPETA, categoria_de
@dataclass
class Movimiento:
"""Un fichero y el sitio al que le toca ir."""
origen: Path
destino: Path
categoria: str
@property
def renombrado(self) -> bool:
"""True si hubo que cambiarle el nombre para evitar una colisión."""
return self.origen.name != self.destino.name
def ficheros_a_ordenar(carpeta: Path, recursivo: bool = False) -> list[Path]:
"""Devuelve los ficheros de la carpeta que hay que clasificar, ya ordenados."""
patron = "**/*" if recursivo else "*"
encontrados = []
for elemento in carpeta.glob(patron):
if not elemento.is_file() or elemento.name.startswith("."):
continue
if elemento.relative_to(carpeta).parts[0] in NOMBRES_DE_CARPETA:
continue
encontrados.append(elemento)
return sorted(encontrados)
def nombre_libre(destino: Path, reservados: set[Path]) -> Path:
"""Devuelve el destino, o una variante con sufijo numérico si ya está ocupado."""
candidato = destino
contador = 1
while candidato.exists() or candidato in reservados:
candidato = destino.with_name(f"{destino.stem}-{contador}{destino.suffix}")
contador += 1
return candidato
def planificar(carpeta: Path, recursivo: bool = False) -> list[Movimiento]:
"""Construye la lista de movimientos sin tocar el disco."""
movimientos = []
reservados: set[Path] = set()
for fichero in ficheros_a_ordenar(carpeta, recursivo):
categoria = categoria_de(fichero)
destino = nombre_libre(carpeta / categoria / fichero.name, reservados)
reservados.add(destino)
movimientos.append(Movimiento(fichero, destino, categoria))
return movimientos
def ejecutar(movimientos: list[Movimiento]) -> tuple[list[Movimiento], list[tuple[Movimiento, OSError]]]:
"""Realiza los movimientos. Devuelve los hechos y los fallidos con su error."""
hechos: list[Movimiento] = []
fallidos: list[tuple[Movimiento, OSError]] = []
for movimiento in movimientos:
try:
movimiento.destino.parent.mkdir(parents=True, exist_ok=True)
movimiento.origen.rename(movimiento.destino)
except OSError as error:
fallidos.append((movimiento, error))
else:
hechos.append(movimiento)
return hechos, fallidos
def contar_por_categoria(movimientos: list[Movimiento]) -> Counter:
"""Cuántos ficheros hay en cada categoría."""
return Counter(movimiento.categoria for movimiento in movimientos)"""La interfaz de terminal del organizador: argumentos, mensajes y códigos de salida."""
import argparse
import sys
from pathlib import Path
from .categorias import CATEGORIAS, CATEGORIA_POR_DEFECTO
from .logica import Movimiento, contar_por_categoria, ejecutar, planificar
ANCHO_NOMBRE = 34
def construir_analizador() -> argparse.ArgumentParser:
analizador = argparse.ArgumentParser(
prog="organizador",
description="Ordena una carpeta moviendo cada fichero a una subcarpeta según su extensión.",
epilog="Prueba siempre primero con --simular.",
)
analizador.add_argument(
"carpeta",
type=Path,
help="carpeta que hay que ordenar",
)
analizador.add_argument(
"-s", "--simular",
action="store_true",
help="enseña lo que haría, pero no mueve nada",
)
analizador.add_argument(
"-r", "--recursivo",
action="store_true",
help="ordena también los ficheros de las subcarpetas",
)
return analizador
def plural(cantidad: int, singular: str, varios: str) -> str:
"""Elige la forma que toca para que los mensajes no canten."""
return singular if cantidad == 1 else varios
def mostrar_plan(movimientos: list[Movimiento], carpeta: Path) -> None:
"""Enseña, fichero a fichero, a dónde va cada uno."""
for movimiento in movimientos:
origen = movimiento.origen.relative_to(carpeta)
destino = movimiento.destino.relative_to(carpeta)
aviso = " (renombrado)" if movimiento.renombrado else ""
print(f" {str(origen):<{ANCHO_NOMBRE}} -> {destino}{aviso}")
def mostrar_resumen(movimientos: list[Movimiento]) -> None:
"""Pinta la tabla con el recuento por categoría."""
recuento = contar_por_categoria(movimientos)
orden = list(CATEGORIAS) + [CATEGORIA_POR_DEFECTO]
print()
print(f" {'CATEGORÍA':<14}{'FICHEROS':>9}")
print(" " + "-" * 23)
for categoria in orden:
if recuento[categoria]:
print(f" {categoria:<14}{recuento[categoria]:>9}")
print(" " + "-" * 23)
print(f" {'TOTAL':<14}{len(movimientos):>9}")
def main(argumentos: list[str] | None = None) -> int:
opciones = construir_analizador().parse_args(argumentos)
carpeta: Path = opciones.carpeta
if not carpeta.exists():
print(f"organizador: la carpeta '{carpeta}' no existe.", file=sys.stderr)
return 1
if not carpeta.is_dir():
print(f"organizador: '{carpeta}' no es una carpeta.", file=sys.stderr)
return 1
movimientos = planificar(carpeta, opciones.recursivo)
if not movimientos:
print(f"No hay nada que ordenar en {carpeta}.")
return 0
if opciones.simular:
print(f"Simulación sobre {carpeta}. No se va a mover nada.\n")
mostrar_plan(movimientos, carpeta)
mostrar_resumen(movimientos)
cuantos = len(movimientos)
verbo = plural(cuantos, "fichero se movería", "ficheros se moverían")
print(f"\n{cuantos} {verbo}. Repite sin --simular para hacerlo.")
return 0
print(f"Ordenando {carpeta}.\n")
hechos, fallidos = ejecutar(movimientos)
mostrar_plan(hechos, carpeta)
mostrar_resumen(hechos)
for movimiento, error in fallidos:
print(f"organizador: no he podido mover '{movimiento.origen.name}': {error}", file=sys.stderr)
verbo = plural(len(hechos), "fichero movido", "ficheros movidos")
if fallidos:
print(f"\n{len(hechos)} {verbo}, {len(fallidos)} con errores.")
return 1
print(f"\n{len(hechos)} {verbo}.")
return 0
if __name__ == "__main__":
sys.exit(main())"""Permite ejecutar el paquete con python3 -m organizador."""
import sys
from .cli import main
sys.exit(main())Unas 190 líneas repartidas en cuatro ficheros, y la mitad son las categorías y la tabla.
Una sesión de uso#
Partimos de esta carpeta de descargas, que tiene de todo a propósito: un fichero oculto, extensiones raras, una subcarpeta y dos nombres repetidos.
descargas/
.oculto
apuntes.txt
boda.mp4
captura.PNG
clip.mov
contrato.pdf
copia-seguridad.zip
foto-playa.jpg
instalador.dmg
logo.svg
notas.xyz
presentacion.pptx
proyecto.tar.gz
ventas-2026.csv
facturas/
contrato.pdf
escaneo.jpg
recibo-luz.pdf
imagenes/
foto-playa.jpgPrimero, sin tocar nada:
python3 -m organizador descargas --simularSimulación sobre descargas. No se va a mover nada.
apuntes.txt -> documentos/apuntes.txt
boda.mp4 -> videos/boda.mp4
captura.PNG -> imagenes/captura.PNG
clip.mov -> videos/clip.mov
contrato.pdf -> documentos/contrato.pdf
copia-seguridad.zip -> comprimidos/copia-seguridad.zip
foto-playa.jpg -> imagenes/foto-playa-1.jpg (renombrado)
instalador.dmg -> otros/instalador.dmg
logo.svg -> imagenes/logo.svg
notas.xyz -> otros/notas.xyz
presentacion.pptx -> documentos/presentacion.pptx
proyecto.tar.gz -> comprimidos/proyecto.tar.gz
ventas-2026.csv -> documentos/ventas-2026.csv
CATEGORÍA FICHEROS
-----------------------
imagenes 3
documentos 4
videos 2
comprimidos 2
otros 2
-----------------------
TOTAL 13
13 ficheros se moverían. Repite sin --simular para hacerlo.Cuatro cosas que confirma esta salida y que no se podían dar por supuestas: captura.PNG ha ido a imágenes pese a la extensión en mayúsculas, foto-playa.jpg se ha renombrado porque ya había una en imagenes/, proyecto.tar.gz ha entrado en comprimidos por su .gz, y ni .oculto ni el contenido de facturas/ aparecen, que es lo que toca sin --recursivo.
El plan convence, así que se ejecuta:
python3 -m organizador descargasOrdenando descargas.
apuntes.txt -> documentos/apuntes.txt
boda.mp4 -> videos/boda.mp4
captura.PNG -> imagenes/captura.PNG
clip.mov -> videos/clip.mov
contrato.pdf -> documentos/contrato.pdf
copia-seguridad.zip -> comprimidos/copia-seguridad.zip
foto-playa.jpg -> imagenes/foto-playa-1.jpg (renombrado)
instalador.dmg -> otros/instalador.dmg
logo.svg -> imagenes/logo.svg
notas.xyz -> otros/notas.xyz
presentacion.pptx -> documentos/presentacion.pptx
proyecto.tar.gz -> comprimidos/proyecto.tar.gz
ventas-2026.csv -> documentos/ventas-2026.csv
CATEGORÍA FICHEROS
-----------------------
imagenes 3
documentos 4
videos 2
comprimidos 2
otros 2
-----------------------
TOTAL 13
13 ficheros movidos.La carpeta queda así:
descargas/
.oculto
comprimidos/
copia-seguridad.zip
proyecto.tar.gz
documentos/
apuntes.txt
contrato.pdf
presentacion.pptx
ventas-2026.csv
facturas/
contrato.pdf
escaneo.jpg
recibo-luz.pdf
imagenes/
captura.PNG
foto-playa-1.jpg
foto-playa.jpg
logo.svg
otros/
instalador.dmg
notas.xyz
videos/
boda.mp4
clip.movSegunda ejecución, para comprobar la idempotencia:
python3 -m organizador descargas
echo $?No hay nada que ordenar en descargas.
0Ahora sí, bajando por las subcarpetas:
python3 -m organizador descargas --recursivoOrdenando descargas.
facturas/contrato.pdf -> documentos/contrato-1.pdf (renombrado)
facturas/escaneo.jpg -> imagenes/escaneo.jpg
facturas/recibo-luz.pdf -> documentos/recibo-luz.pdf
CATEGORÍA FICHEROS
-----------------------
imagenes 1
documentos 2
-----------------------
TOTAL 3
3 ficheros movidos.Los dos contrato.pdf han sobrevivido los dos, y la carpeta facturas se queda vacía, porque el programa mueve ficheros y no borra carpetas. Eso es deliberado: borrar carpetas es otra decisión y merece su propia opción, que tienes en las ampliaciones.
Y el caso de error:
python3 -m organizador descargas-de-ayer
echo $?organizador: la carpeta 'descargas-de-ayer' no existe.
1Lo que se ha usado#
| Del curso | Dónde |
|---|---|
pathlib: glob, stem, suffix, with_name, relative_to, rename, mkdir | Todo logica.py |
| Comprensiones de diccionario anidadas | El índice invertido de categorias.py |
| Conjuntos y su unión | Las extensiones y NOMBRES_DE_CARPETA |
| Dataclasses y propiedades | Movimiento |
Counter | El recuento por categoría |
try/except/else con OSError | ejecutar |
| f-strings con ancho variable | Las dos tablas |
argparse con posicional y banderas | cli.py |
sys.stderr y códigos de salida | main |
| Paquetes propios e importaciones relativas | Los cuatro ficheros |