LABS · JULIÁN LÓPEZ JIMÉNEZ · RECURSO GRATUITO

Ordena tus archivos sin moverlos

Cinco retos con archivos ficticios en el playground: observa, clasifica, detecta conflictos y construye un plan sin modificar originales. Sin cuenta ni tarjeta.

Qué necesitas: Leer funciones, listas y diccionarios de Python. Navegador con JavaScript y WebAssembly. Ordenador recomendado. Archivos virtuales temporales.

Tu resultado: Un plan verificable, sin movimientos ni sobrescrituras.

1 · Observa tu bandeja

Una automatización empieza por observar. Path(carpeta).iterdir() produce las entradas inmediatas: archivos y directorios. p.name devuelve solo el nombre y sorted hace repetible un recorrido cuyo orden no está garantizado. No uses rglob todavía: entrar en subcarpetas sería otra regla. La función devuelve datos; mostrar pertenece a quien la llama. preparar() crea una raíz temporal ficticia y estado() permite comparar antes y después. Ambos están explicados en laboratorio.py. Los archivos se descartan tras cada ejecución: guardar código no guarda carpetas. Define inventariar(carpeta): lista ordenada de nombres inmediatos, incluidos directorios. No imprime ni modifica. Una carpeta vacía devuelve [].

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado

def inventariar(carpeta):
    # Implementa el contrato.
    pass

# Explora llamando a tu función sobre una raíz nueva creada con preparar().
Necesito una pista

Convierte carpeta a Path y recorre iterdir(). → Obtén p.name, no la ruta absoluta. → Devuelve sorted de los nombres, sin print.

Ver solución de este reto
from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado

def inventariar(carpeta):
    """Immediate children only. Never follow links or mutate the input folder."""
    return sorted(p.name for p in Path(carpeta).iterdir())

El resultado incluye borradores porque el contrato pide entradas, no solo archivos. Ordenarlo permite comparar ejecuciones. No se abre ningún contenido ni se crean salidas.

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path
import tempfile

def preparar(datos=None):
    # New disposable folder every time, never Documents or Downloads.
    if datos is None: datos={'nota.TXT':'hola\n','datos.csv':'id,valor\n1,7\n','README':'x'}
    if not isinstance(datos, dict):
        raise ValueError('Usa un diccionario de nombres y textos ficticios')
    nombres=set()
    for nombre, contenido in datos.items():
        if (not isinstance(nombre, str) or not nombre or nombre in ('.', '..') or nombre.casefold() == 'borradores'
                or any(c in nombre for c in ('/', chr(92), ':', chr(0)))
                or not isinstance(contenido, str)):
            raise ValueError('Solo nombres inmediatos y contenido de texto')
        clave=nombre.casefold()
        if clave in nombres:
            raise ValueError('Nombres repetidos sin distinguir mayúsculas')
        nombres.add(clave)
    raiz=Path(tempfile.mkdtemp(prefix='labs-auto-'))
    (raiz/'inbox').mkdir()
    for nombre,contenido in datos.items():
        destino=raiz/'inbox'/nombre
        destino.parent.mkdir(parents=True,exist_ok=True)
        destino.write_text(contenido,encoding='utf-8')
    (raiz/'inbox'/'borradores').mkdir(exist_ok=True)
    return raiz

def estado(raiz):
    resultado={}
    for p in Path(raiz).rglob('*'):
        clave=p.relative_to(raiz).as_posix()
        if p.is_symlink(): resultado[clave]='<enlace>'
        elif p.is_dir(): resultado[clave+'/']='<carpeta>'
        else: resultado[clave]=p.read_bytes().hex()
    return resultado

Archivo de apoyo: apoyo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

Comprueba: Añade un nombre con espacios y una carpeta con un archivo dentro. Explica por qué solo aparece la carpeta.

2 · Clasifica sin mover

La política relaciona extensiones y categorías con un diccionario. get permite un valor por defecto sin KeyError. Clasificar no crea carpetas ni mueve: devuelve una decisión fácil de probar. Conserva el diccionario para que una llamada no altere la siguiente. Las funciones deben admitir otras categorías sin que tengas que reescribirlas. Define clasificar(nombre,politica): politica.get(extension(nombre),"sin-clasificar"). No imprime ni modifica politica.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import extension

def clasificar(nombre, politica):
    # Implementa el contrato.
    pass

# Explora llamando a tu función sobre una raíz nueva creada con preparar().
Necesito una pista

Reutiliza extension. → El valor por defecto es sin-clasificar. → Devuelve el resultado de get, sin escribir nada.

Ver solución de este reto
from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import extension

def clasificar(nombre, politica):
    return politica.get(extension(nombre), 'sin-clasificar')

Una política diferente produce otra decisión con el mismo código. Clasificar se puede explicar antes de ejecutar efectos sobre archivos.

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path
import tempfile

def preparar(datos=None):
    # New disposable folder every time, never Documents or Downloads.
    if datos is None: datos={'nota.TXT':'hola\n','datos.csv':'id,valor\n1,7\n','README':'x'}
    if not isinstance(datos, dict):
        raise ValueError('Usa un diccionario de nombres y textos ficticios')
    nombres=set()
    for nombre, contenido in datos.items():
        if (not isinstance(nombre, str) or not nombre or nombre in ('.', '..') or nombre.casefold() == 'borradores'
                or any(c in nombre for c in ('/', chr(92), ':', chr(0)))
                or not isinstance(contenido, str)):
            raise ValueError('Solo nombres inmediatos y contenido de texto')
        clave=nombre.casefold()
        if clave in nombres:
            raise ValueError('Nombres repetidos sin distinguir mayúsculas')
        nombres.add(clave)
    raiz=Path(tempfile.mkdtemp(prefix='labs-auto-'))
    (raiz/'inbox').mkdir()
    for nombre,contenido in datos.items():
        destino=raiz/'inbox'/nombre
        destino.parent.mkdir(parents=True,exist_ok=True)
        destino.write_text(contenido,encoding='utf-8')
    (raiz/'inbox'/'borradores').mkdir(exist_ok=True)
    return raiz

def estado(raiz):
    resultado={}
    for p in Path(raiz).rglob('*'):
        clave=p.relative_to(raiz).as_posix()
        if p.is_symlink(): resultado[clave]='<enlace>'
        elif p.is_dir(): resultado[clave+'/']='<carpeta>'
        else: resultado[clave]=p.read_bytes().hex()
    return resultado

Archivo de apoyo: apoyo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}


def extension(nombre):
    return PurePosixPath(nombre).suffix.lower()

Comprueba: Cambia .txt a documentos sin modificar la función.

3 · Detecta una colisión

Operaciones válidas por separado pueden entrar en conflicto como conjunto. Agrupa destinos por casefold: la herramienta rechaza también colisiones que solo difieren en mayúsculas. Es una decisión portable y conservadora. Un destino conflictivo aparece una vez en la salida ordenada. No inventes sufijos copia-2 para solucionarlo sin una política explícita. Tampoco confundir un destino repetido en el plan con otro ya ocupado en disco. Define conflictos(plan): destinos normalizados con casefold que aparezcan en más de una operación, sin repetirlos y ordenados. No modifica plan; vacío devuelve [].

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado

def conflictos(plan):
    # Implementa el contrato.
    pass

# Explora llamando a tu función sobre una raíz nueva creada con preparar().
Necesito una pista

Agrupa orígenes por destino normalizado. → Cuenta operaciones, no solo nombres distintos. → Devuelve las claves de grupos de más de un elemento, ordenadas.

Ver solución de este reto
from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado

def conflictos(plan):
    grupos = {}
    for op in plan:
        grupos.setdefault(op['destino'].casefold(), []).append(op['origen'])
    return sorted(clave for clave, origenes in grupos.items() if len(origenes) > 1)

La colisión se descubre antes de escribir. Superar esta prueba no reserva el destino ni garantiza vigencia del origen; son controles posteriores.

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path
import tempfile

def preparar(datos=None):
    # New disposable folder every time, never Documents or Downloads.
    if datos is None: datos={'nota.TXT':'hola\n','datos.csv':'id,valor\n1,7\n','README':'x'}
    if not isinstance(datos, dict):
        raise ValueError('Usa un diccionario de nombres y textos ficticios')
    nombres=set()
    for nombre, contenido in datos.items():
        if (not isinstance(nombre, str) or not nombre or nombre in ('.', '..') or nombre.casefold() == 'borradores'
                or any(c in nombre for c in ('/', chr(92), ':', chr(0)))
                or not isinstance(contenido, str)):
            raise ValueError('Solo nombres inmediatos y contenido de texto')
        clave=nombre.casefold()
        if clave in nombres:
            raise ValueError('Nombres repetidos sin distinguir mayúsculas')
        nombres.add(clave)
    raiz=Path(tempfile.mkdtemp(prefix='labs-auto-'))
    (raiz/'inbox').mkdir()
    for nombre,contenido in datos.items():
        destino=raiz/'inbox'/nombre
        destino.parent.mkdir(parents=True,exist_ok=True)
        destino.write_text(contenido,encoding='utf-8')
    (raiz/'inbox'/'borradores').mkdir(exist_ok=True)
    return raiz

def estado(raiz):
    resultado={}
    for p in Path(raiz).rglob('*'):
        clave=p.relative_to(raiz).as_posix()
        if p.is_symlink(): resultado[clave]='<enlace>'
        elif p.is_dir(): resultado[clave+'/']='<carpeta>'
        else: resultado[clave]=p.read_bytes().hex()
    return resultado

Archivo de apoyo: apoyo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

Comprueba: Añade tres operaciones hacia el mismo destino y observa una sola clave conflictiva.

4 · Produce un plan completo

Reutiliza los apoyos de lectura, clasificación y validación para obtener un plan sin tocar originales. apoyo.py está disponible: no contiene funciones para aplicar ni recuperar movimientos. Este recurso requiere leer funciones, listas y diccionarios; si todavía no puedes, comienza por los retos gratuitos de De cero a Python. Crear un plan no crea las carpetas de salida. Antes de llamar a archivos, comprueba que raíz e inbox son directorios reales y no enlaces: un inventario vacío no demuestra que la raíz sea válida. Define construir_plan(raiz,politica): valida política, crea una operación por archivo inmediato admitido de inbox, en orden, y valida el conjunto. No imprime ni mueve. Rechaza con ValueError raíz o inbox ausentes, no directorios o enlaces, antes de inventariar.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import archivos, extension, clasificar, validar_politica, huella, ruta, operacion, conflictos, validar_plan

def construir_plan(raiz, politica):
    # Implementa el contrato.
    pass

# Explora llamando a tu función sobre una raíz nueva creada con preparar().
Necesito una pista

Valida política antes del recorrido. → Combina archivos y operacion. → Valida la lista completa antes de devolverla.

Ver solución de este reto
from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import archivos, extension, clasificar, validar_politica, huella, ruta, operacion, conflictos, validar_plan

def construir_plan(raiz, politica):
    politica = validar_politica(politica)
    raiz = Path(raiz)
    bandeja = raiz / 'inbox'
    if raiz.is_symlink() or not raiz.is_dir() or bandeja.is_symlink() or not bandeja.is_dir():
        raise ValueError('Raíz e inbox deben ser carpetas reales, no enlaces')
    plan = [operacion(raiz, nombre, politica) for nombre in archivos(bandeja)]
    return validar_plan(raiz, plan)

El plan describe intención, rutas y bytes observados. Es útil por sí mismo para revisar una automatización antes de decidir modificar archivos.

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path
import tempfile

def preparar(datos=None):
    # New disposable folder every time, never Documents or Downloads.
    if datos is None: datos={'nota.TXT':'hola\n','datos.csv':'id,valor\n1,7\n','README':'x'}
    if not isinstance(datos, dict):
        raise ValueError('Usa un diccionario de nombres y textos ficticios')
    nombres=set()
    for nombre, contenido in datos.items():
        if (not isinstance(nombre, str) or not nombre or nombre in ('.', '..') or nombre.casefold() == 'borradores'
                or any(c in nombre for c in ('/', chr(92), ':', chr(0)))
                or not isinstance(contenido, str)):
            raise ValueError('Solo nombres inmediatos y contenido de texto')
        clave=nombre.casefold()
        if clave in nombres:
            raise ValueError('Nombres repetidos sin distinguir mayúsculas')
        nombres.add(clave)
    raiz=Path(tempfile.mkdtemp(prefix='labs-auto-'))
    (raiz/'inbox').mkdir()
    for nombre,contenido in datos.items():
        destino=raiz/'inbox'/nombre
        destino.parent.mkdir(parents=True,exist_ok=True)
        destino.write_text(contenido,encoding='utf-8')
    (raiz/'inbox'/'borradores').mkdir(exist_ok=True)
    return raiz

def estado(raiz):
    resultado={}
    for p in Path(raiz).rglob('*'):
        clave=p.relative_to(raiz).as_posix()
        if p.is_symlink(): resultado[clave]='<enlace>'
        elif p.is_dir(): resultado[clave+'/']='<carpeta>'
        else: resultado[clave]=p.read_bytes().hex()
    return resultado

Archivo de apoyo: apoyo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}


def archivos(carpeta):
    return sorted(p.name for p in Path(carpeta).iterdir()
                  if not p.is_symlink() and p.is_file())


def extension(nombre):
    return PurePosixPath(nombre).suffix.lower()


def clasificar(nombre, politica):
    return politica.get(extension(nombre), 'sin-clasificar')


def validar_politica(politica):
    if not isinstance(politica, dict) or not politica:
        raise ValueError('La política debe ser un diccionario no vacío')
    for sufijo, categoria in politica.items():
        if (not isinstance(sufijo, str) or not re.fullmatch(r'\.[a-z0-9]+', sufijo)
                or not isinstance(categoria, str)
                or not re.fullmatch(r'[a-z][a-z0-9-]{0,30}', categoria)):
            raise ValueError('Extensión o categoría inválida')
    return dict(politica)


def huella(archivo):
    return hashlib.sha256(Path(archivo).read_bytes()).hexdigest()


def ruta(raiz, relativa, prefijo):
    # Portable plan format: relative POSIX paths, including when run on Windows.
    if (not isinstance(relativa, str) or not relativa or '\\' in relativa
            or ':' in relativa or '\x00' in relativa):
        raise ValueError('Ruta relativa inválida')
    trozos = relativa.split('/')
    if (trozos[0] != prefijo or len(trozos) < 2
            or any(t in ('', '.', '..') for t in trozos)):
        raise ValueError('Ruta fuera del contrato')
    raiz = Path(raiz)
    if raiz.is_symlink() or not raiz.is_dir():
        raise ValueError('La raíz debe ser una carpeta real del laboratorio')
    actual = raiz
    for trozo in trozos:
        actual = actual / trozo
        if actual.is_symlink():
            raise ValueError('No se admiten enlaces simbólicos')
    resuelta = actual.resolve()
    if not resuelta.is_relative_to(raiz.resolve()):
        raise ValueError('Ruta fuera de la raíz')
    return actual


def operacion(raiz, nombre, politica):
    politica = validar_politica(politica)
    if not isinstance(nombre, str) or '/' in nombre or '\\' in nombre:
        raise ValueError('Solo nombres de archivo, sin carpetas')
    origen = 'inbox/' + nombre
    archivo = ruta(raiz, origen, 'inbox')
    if not archivo.is_file():
        raise ValueError('El origen debe ser un archivo')
    destino = 'salida/' + clasificar(nombre, politica) + '/' + nombre
    ruta(raiz, destino, 'salida')
    return {'origen': origen, 'destino': destino, 'sha256': huella(archivo)}


def conflictos(plan):
    grupos = {}
    for op in plan:
        grupos.setdefault(op['destino'].casefold(), []).append(op['origen'])
    return sorted(clave for clave, origenes in grupos.items() if len(origenes) > 1)


def validar_plan(raiz, plan):
    if not isinstance(plan, list):
        raise ValueError('El plan debe ser una lista')
    origenes = set()
    for op in plan:
        if not isinstance(op, dict) or set(op) != {'origen', 'destino', 'sha256'}:
            raise ValueError('Esquema de operación inválido')
        ruta(raiz, op['origen'], 'inbox')
        ruta(raiz, op['destino'], 'salida')
        if not isinstance(op['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', op['sha256']):
            raise ValueError('Huella inválida')
        clave = op['origen'].casefold()
        if clave in origenes:
            raise ValueError('Origen repetido')
        origenes.add(clave)
    if conflictos(plan):
        raise ValueError('Destinos repetidos, también sin distinguir mayúsculas')
    return [dict(op) for op in plan]

Comprueba: Cambia la categoría de .txt a documentos y demuestra que solo cambia el plan.

5 · Demuestra que simular no mueve

El dry-run observa decisiones sin crear carpetas ni mover archivos. Primero valida todo el plan. Después cada operación se informa como origen-cambiado, conflicto o previsto, en ese orden. disponible comprueba también nombres que solo difieren en mayúsculas. Previsto no reserva el destino: aplicar deberá revisarlo otra vez. El informe copia los campos originales y añade estado sin contaminar el plan recibido. Define simular(raiz,plan): lista copiada con estado origen-cambiado si falla vigente, conflicto si no hay destino disponible, previsto en otro caso. Valida primero y no crea ni carpetas.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import huella, ruta, conflictos, validar_plan, vigente, libre, disponible

def simular(raiz, plan):
    # Implementa el contrato.
    pass

# Explora llamando a tu función sobre una raíz nueva creada con preparar().
Necesito una pista

Valida el conjunto antes del bucle. → Decide estado sin mkdir ni escritura. → Devuelve un diccionario nuevo con {**op, "estado": estado}.

Ver solución de este reto
from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import huella, ruta, conflictos, validar_plan, vigente, libre, disponible

def simular(raiz, plan):
    limpio = validar_plan(raiz, plan)
    return [{**op, 'estado': ('origen-cambiado' if not vigente(raiz, op)
             else 'conflicto' if not disponible(raiz, op) else 'previsto')} for op in limpio]

La simulación permite explicar una intención. No es una transacción ni una garantía de que el entorno siga igual al confirmar.

Archivo de apoyo: laboratorio.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path
import tempfile

def preparar(datos=None):
    # New disposable folder every time, never Documents or Downloads.
    if datos is None: datos={'nota.TXT':'hola\n','datos.csv':'id,valor\n1,7\n','README':'x'}
    if not isinstance(datos, dict):
        raise ValueError('Usa un diccionario de nombres y textos ficticios')
    nombres=set()
    for nombre, contenido in datos.items():
        if (not isinstance(nombre, str) or not nombre or nombre in ('.', '..') or nombre.casefold() == 'borradores'
                or any(c in nombre for c in ('/', chr(92), ':', chr(0)))
                or not isinstance(contenido, str)):
            raise ValueError('Solo nombres inmediatos y contenido de texto')
        clave=nombre.casefold()
        if clave in nombres:
            raise ValueError('Nombres repetidos sin distinguir mayúsculas')
        nombres.add(clave)
    raiz=Path(tempfile.mkdtemp(prefix='labs-auto-'))
    (raiz/'inbox').mkdir()
    for nombre,contenido in datos.items():
        destino=raiz/'inbox'/nombre
        destino.parent.mkdir(parents=True,exist_ok=True)
        destino.write_text(contenido,encoding='utf-8')
    (raiz/'inbox'/'borradores').mkdir(exist_ok=True)
    return raiz

def estado(raiz):
    resultado={}
    for p in Path(raiz).rglob('*'):
        clave=p.relative_to(raiz).as_posix()
        if p.is_symlink(): resultado[clave]='<enlace>'
        elif p.is_dir(): resultado[clave+'/']='<carpeta>'
        else: resultado[clave]=p.read_bytes().hex()
    return resultado

Archivo de apoyo: apoyo.py

Guárdalo con este nombre junto a main.py de este reto. No lo pegues dentro de main.py.

from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}


def huella(archivo):
    return hashlib.sha256(Path(archivo).read_bytes()).hexdigest()


def ruta(raiz, relativa, prefijo):
    # Portable plan format: relative POSIX paths, including when run on Windows.
    if (not isinstance(relativa, str) or not relativa or '\\' in relativa
            or ':' in relativa or '\x00' in relativa):
        raise ValueError('Ruta relativa inválida')
    trozos = relativa.split('/')
    if (trozos[0] != prefijo or len(trozos) < 2
            or any(t in ('', '.', '..') for t in trozos)):
        raise ValueError('Ruta fuera del contrato')
    raiz = Path(raiz)
    if raiz.is_symlink() or not raiz.is_dir():
        raise ValueError('La raíz debe ser una carpeta real del laboratorio')
    actual = raiz
    for trozo in trozos:
        actual = actual / trozo
        if actual.is_symlink():
            raise ValueError('No se admiten enlaces simbólicos')
    resuelta = actual.resolve()
    if not resuelta.is_relative_to(raiz.resolve()):
        raise ValueError('Ruta fuera de la raíz')
    return actual


def conflictos(plan):
    grupos = {}
    for op in plan:
        grupos.setdefault(op['destino'].casefold(), []).append(op['origen'])
    return sorted(clave for clave, origenes in grupos.items() if len(origenes) > 1)


def validar_plan(raiz, plan):
    if not isinstance(plan, list):
        raise ValueError('El plan debe ser una lista')
    origenes = set()
    for op in plan:
        if not isinstance(op, dict) or set(op) != {'origen', 'destino', 'sha256'}:
            raise ValueError('Esquema de operación inválido')
        ruta(raiz, op['origen'], 'inbox')
        ruta(raiz, op['destino'], 'salida')
        if not isinstance(op['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', op['sha256']):
            raise ValueError('Huella inválida')
        clave = op['origen'].casefold()
        if clave in origenes:
            raise ValueError('Origen repetido')
        origenes.add(clave)
    if conflictos(plan):
        raise ValueError('Destinos repetidos, también sin distinguir mayúsculas')
    return [dict(op) for op in plan]


def vigente(raiz, op):
    origen = ruta(raiz, op['origen'], 'inbox')
    return origen.is_file() and huella(origen) == op['sha256']


def libre(archivo):
    """Conservative portable collision rule for a name in an existing directory."""
    archivo = Path(archivo)
    if not archivo.parent.exists():
        return True
    if not archivo.parent.is_dir():
        return False
    return not any(p.name.casefold() == archivo.name.casefold()
                   for p in archivo.parent.iterdir())


def disponible(raiz, op):
    return libre(ruta(raiz, op['destino'], 'salida'))

Comprueba: Compara estado antes y después de simular un plan de tres archivos.

Tu turno

Cambia nombres y política. Explica cada destino y demuestra con estado(raiz) que no escribes.

Ver solución y explicación
from pathlib import Path, PurePosixPath
import hashlib
import json
import re
POLITICA = {'.txt': 'texto', '.csv': 'datos', '.json': 'datos'}

from laboratorio import preparar, estado
from apoyo import huella, ruta, conflictos, validar_plan, vigente, libre, disponible

def simular(raiz, plan):
    limpio = validar_plan(raiz, plan)
    return [{**op, 'estado': ('origen-cambiado' if not vigente(raiz, op)
             else 'conflicto' if not disponible(raiz, op) else 'previsto')} for op in limpio]

Cada ejecución empieza de cero. Para practicar fuera guarda main.py y, en la misma carpeta, laboratorio.py y apoyo.py del mismo reto. No mezcles versiones. Necesitas Python 3.12 o posterior; usa solo datos ficticios. La guía muestra cada archivo por separado.

Explícalo con tus palabras

Repite con datos distintos antes de marcarlo como practicado. Esta es una autoevaluación, no una certificación.

Si quieres continuar

El curso añade aplicación controlada, manifiestos, reintentos, integridad y recuperación. No necesitas comprar para terminar este recurso.

Ver el curso Tu primera automatización con Python y su temario

Fuente: Recurso original de Labs. Puedes guardar este HTML, abrirlo sin conexión o imprimirlo como PDF desde tu navegador. No guarda tus respuestas ni el progreso de tu cuenta.