Python esencial: de la sintaxis al testing

Aprende Python 3.13 de forma práctica, directa y profesional: instalación multiplataforma, resolución real de conflictos entre venv y Conda/Miniconda, depuración visual paso a paso en Visual Studio Code, fundamentos sólidos, POO moderna, AsyncIO, testing con pytest y un proyecto modular completo.

14 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil Python 3.13
14
Capítulos
5
Mini-retos con solución
5
Partes del curso
0
Requisitos previos
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables y puntos clave al final. Practica cada ejemplo: es la única vía para dominar cualquier tema.

1 · Instalación multiplataforma y pyenv

Básico ~15 min

Python es hoy el lenguaje de programación más versátil y demandado del desarrollo de software: desde backend web y microservicios, hasta automatización de sistemas, análisis de datos e inteligencia artificial. En este capítulo configurarás un entorno limpio y profesional en Ubuntu, Windows o macOS, aprenderás a alternar versiones sin conflictos con pyenv y darás tus primeros pasos con el nuevo REPL interactivo de Python 3.13.

  • Instalar la versión oficial de Python 3.13 en cualquier sistema operativo.
  • Evitar los problemas típicos de variables de entorno (PATH).
  • Dominar pyenv para alternar entre diferentes versiones de Python por proyecto.
  • Aprovechar las capacidades del nuevo intérprete interactivo con comandos rápidos y modo pegado.

Instalación en Linux (Ubuntu 24.04 LTS y derivados)

En distribuciones basadas en Debian y Ubuntu, Python viene instalado para tareas internas del sistema operativo. Sin embargo, para programar necesitas asegurar los paquetes de cabecera, el gestor de paquetes pip y el módulo de aislamiento python3-venv:

# 1. Actualizar repositorios del sistema sudo apt update && sudo apt upgrade -y # 2. Instalar intérprete, pip y soporte de entornos virtuales sudo apt install python3 python3-pip python3-venv -y # 3. Comprobar la versión instalada y la ruta del binario python3 --version which python3
Python 3.13.0 /usr/bin/python3

Instalación en Windows 11 / 10

En Windows, la forma estándar y recomendada es utilizar el instalador ejecutable oficial proporcionado por la Python Software Foundation:

  1. Ingresa al portal oficial: python.org/downloads/windows.
  2. Descarga el instalador de 64 bits para Python 3.13 (python-3.13.x-amd64.exe).
  3. PASO CRÍTICO: Al ejecutar el instalador, marca obligatoriamente la casilla inferior "Add python.exe to PATH" antes de pulsar Install Now. Si olvidas esta casilla, la terminal de Windows no reconocerá el comando python.
  4. Opcional pero recomendado al finalizar: haz clic en "Disable path length limit" para eliminar la restricción de 260 caracteres en rutas de Windows.
# Verificar en PowerShell o Windows Terminal (cmd): python --version pip --version # El lanzador oficial de Windows (py launcher) detecta versiones instaladas: py -0
Python 3.13.0 pip 24.2 from C:\Users\percy\AppData\Local\Programs\Python\Python313\Lib\site-packages\pip (python 3.13) -V:3.13 * Python 3.13 (64-bit)

Instalación en macOS con Homebrew

macOS incluye una versión mínima de Python para el sistema que nunca debes modificar. La práctica profesional estándar en Mac es utilizar el gestor de paquetes Homebrew:

# 1. Si no tienes Homebrew instalado, instálalo desde terminal: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # 2. Instalar la versión actual de Python: brew install python # 3. Comprobar instalación aislada de Homebrew: python3 --version brew --prefix python
Python 3.13.0 /opt/homebrew/opt/python@3.13

Gestión de múltiples versiones con pyenv

En el mundo real, trabajarás en proyectos heredados que requieren Python 3.10 o 3.11, mientras que tus nuevos desarrollos aprovechan Python 3.13. Modificar la versión global del sistema operativo para cada proyecto es una receta para el desastre. La solución universal es pyenv:

# En Linux/macOS: instalar el instalador automático oficial de pyenv curl https://pyenv.run | bash # Añadir a tu archivo de configuración de shell (~/.bashrc o ~/.zshrc): export PYENV_ROOT="$HOME/.pyenv" [[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" eval "$(pyenv init - bash)" # Recargar la sesión de terminal source ~/.bashrc

Una vez activo pyenv, puedes compilar e instalar cualquier versión de Python y asignarla automáticamente a un directorio específico mediante el archivo .python-version:

# Listar versiones disponibles para instalar pyenv install --list | grep " 3.13" # Instalar Python 3.13.0 en tu espacio de usuario pyenv install 3.13.0 # Ver todas las versiones instaladas en tu máquina pyenv versions # Fijar una versión específica ÚNICAMENTE para la carpeta del proyecto actual: mkdir mi_proyecto && cd mi_proyecto pyenv local 3.13.0 # Verificar: el comando python apuntará automáticamente a 3.13 en esta carpeta python --version
system * 3.13.0 (set by /home/percy/mi_proyecto/.python-version) Python 3.13.0

El nuevo REPL interactivo de Python 3.13

Python 3.13 renovó por completo su consola interactiva (REPL, *Read-Eval-Print Loop*), integrando características heredadas de PyPy que transforman la experiencia de aprendizaje:

  • Edición multilínea fluida: Navega con las flechas arriba/abajo dentro de bloques de código indentados sin romperlos.
  • Sintaxis con colores ANSI: Resaltado visual en tiempo real de palabras clave, cadenas y números.
  • Comandos rápidos: Ahora puedes escribir exit, quit, help o clear directamente sin los paréntesis.
  • Atajos de teclado indispensables: F1 (ayuda), F2 (historial de comandos previos) y F3 (modo de pegado limpio sin sangrías duplicadas).
# Inicia el REPL ejecutando python3 en tu terminal
# Verás el prompt interactivo >>>

def saludar(nombre: str) -> str:
    return f"¡Bienvenido a Python 3.13, {nombre}!"

mensaje = saludar("Percy")
print(mensaje)
¡Bienvenido a Python 3.13, Percy!
Consejo de ejecución: Para guardar tus programas en disco, crea un archivo con extensión .py (por ejemplo, app.py) y ejecútalo desde tu terminal con python3 app.py.
Método Ventaja principal Cuándo utilizarlo
Instalador / apt / brew Rápido, oficial e integrado con el sistema. Para tener la versión base de trabajo en tu ordenador personal.
pyenv Aislamiento total de versiones por carpeta (.python-version). Indispensable en equipos de desarrollo con múltiples proyectos.
REPL interactivo Retroalimentación inmediata sin crear archivos. Para probar algoritmos rápidos, consultar tipos y explorar librerías.

Puntos clave del Capítulo 1

  • En Linux instala siempre los paquetes complementarios python3-pip y python3-venv.
  • En Windows marca siempre la casilla "Add python.exe to PATH" para habilitar el comando global.
  • En macOS evita modificar el Python preinstalado y utiliza Homebrew (brew install python).
  • Usa pyenv para alternar entre versiones de Python por proyecto sin contaminar tu sistema operativo.
  • El REPL de Python 3.13 incorpora comandos directos (exit, clear) y soporte multilínea con colores.

2 · Entornos y resolución de conflictos: venv vs Conda

Intermedio ~20 min

Uno de los errores más frustrantes para quien programa en Python es la corrupción de librerías globales y los conflictos de versiones cruzadas. En este capítulo desmitificamos la batalla entre venv + pip y el ecosistema Conda / Miniconda / Anaconda: entenderás por qué existen ambos mundos, cómo resuelven las colisiones de paquetes y qué herramienta elegir según el tipo de proyecto.

  • Comprender el conflicto de dependencias (*Dependency Hell*) y la protección moderna del PEP 668.
  • Dominar la creación, activación y congelamiento de entornos virtuales estándar con venv.
  • Diferenciar con total claridad entre Anaconda, Miniconda y Conda sin mitos ni bloatware.
  • Aprender cómo el solucionador SAT libmamba de Conda resuelve binarios de C/C++ y CUDA.
  • Dominar los manifiestos de reproducción: requirements.txt frente a environment.yml.

El problema de las dependencias globales y el PEP 668

Si instalas todas tus librerías en el Python del sistema operativo con pip install, tarde o temprano ocurrirá una colisión insalvable:

Conflicto clásico: Tu proyecto web A requiere pydantic<2.0.0, pero tu proyecto B requiere la versión moderna pydantic>=2.8.0. En un entorno global compartido, una instalación sobrescribe a la otra y rompe uno de los proyectos.

Para evitar que los usuarios rompan herramientas esenciales de las distribuciones Linux (como los gestores de paquetes del SO), las versiones modernas de Python y Linux implementan la directiva PEP 668: si intentas ejecutar pip install sin un entorno virtual activo, el sistema arrojará el error error: externally-managed-environment. La solución obligatoria es utilizar entornos virtuales aislados.

El estándar de la industria: venv + pip

El módulo venv forma parte de la biblioteca estándar de Python. Crea un subdirectorio aislado (habitualmente llamado .venv) que contiene un enlace simbólico al binario de Python, sus propios árboles de carpetas y una copia independiente de pip.

# 1. Crear un entorno virtual aislado en la raíz de tu proyecto python3 -m venv .venv # 2. Activar el entorno según tu sistema operativo: # En Linux y macOS (Bash/Zsh): source .venv/bin/activate # En Windows (Command Prompt clásico cmd.exe): .venv\Scripts\activate.bat # En Windows (PowerShell moderno): .venv\Scripts\Activate.ps1
Nota para Windows PowerShell: Si PowerShell bloquea la activación con un error de scripts restringidos, ejecuta una sola vez en PowerShell como usuario actual: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser.

Cuando el entorno está activo, tu terminal mostrará el prefijo (.venv). A partir de ese momento, cualquier comando pip install afectará únicamente a esta carpeta:

# Instalar paquetes dentro del entorno aislado (.venv) $ pip install requests fastapi uvicorn # Inspeccionar qué paquetes y versiones exactas residen en este entorno (.venv) $ pip list # Congelar dependencias para compartirlas con otros desarrolladores o producción (.venv) $ pip freeze > requirements.txt # En otra máquina: reproducir exactamente el mismo entorno en un solo comando (.venv) $ pip install -r requirements.txt # Para salir del entorno virtual y volver al shell global (.venv) $ deactivate

Ejemplo de un archivo requirements.txt profesional con fijación estricta de versiones:

# requirements.txt
fastapi==0.115.0
pydantic==2.9.2
requests==2.32.3
uvicorn==0.31.0

El universo Conda: Anaconda vs Miniconda vs Conda

En tutoriales de ciencia de datos e inteligencia artificial es muy común encontrar referencias a Conda. Es vital distinguir con precisión qué representa cada término:

Herramienta Qué es en realidad Tamaño en disco Recomendación
Anaconda Distribución monolítica con interfaz gráfica (Anaconda Navigator), Jupyter Notebook, Spyder y más de 250 paquetes científicos preinstalados. ~3 GB a 5 GB Pesada e innecesaria para desarrollo ágil y servidores de producción.
Miniconda Instalador oficial minimalista que incluye únicamente el comando conda, Python y un puñado de utilidades esenciales. ~100 MB La opción recomendada por profesionales: tú instalas solo lo que necesitas.
Conda El gestor de paquetes y de entornos multiplataforma subyacente. Binario CLI El motor que ejecuta los comandos de terminal.

¿Por qué la Ciencia de Datos e IA prefieren Conda?

Aquí radica la diferencia conceptual más importante entre pip y conda:

  • pip es un gestor de paquetes de Python: Descarga código o binarios compilados (*wheels*) desde PyPI. Si un paquete necesita librerías externas del sistema (como bibliotecas matemáticas de alto rendimiento escritas en Fortran o C, o controladores de NVIDIA CUDA para tarjetas gráficas), pip asume que ya están instaladas y configuradas en tu sistema operativo. Si faltan, la instalación fallará.
  • conda es un gestor de paquetes binarios a nivel de sistema: No solo instala librerías de Python, sino que empaqueta las bibliotecas nativas compiladas de C/C++, Fortran, OpenBLAS, MKL (Intel Math Kernel Library) y el kit de herramientas CUDA completo de NVIDIA de forma aislada e independiente de lo que tenga instalado tu sistema operativo.

Resolución de conflictos con libmamba

Históricamente, Conda tenía fama de ser lento resolviendo dependencias complejas (se quedaba varios minutos en el paso "Solving environment..."). En las versiones modernas, Conda adoptó por defecto el motor libmamba: un solucionador SAT escrito en C++ que calcula la compatibilidad de grafos de librerías en cuestión de segundos.

# 1. Crear un entorno Conda especificando la versión exacta de Python deseada: conda create -n ciencia_datos python=3.13 -y # 2. Activar el entorno Conda: conda activate ciencia_datos # 3. Instalar paquetes resolviendo compatibilidad binaria automáticamente: conda install numpy pandas scikit-learn -y # 4. Exportar el manifiesto completo del entorno (environment.yml): conda env export --no-builds > environment.yml # 5. Replicar el entorno en otra máquina desde el archivo YAML: conda env create -f environment.yml # 6. Desactivar el entorno: conda deactivate

Ejemplo del archivo declarativo environment.yml de Conda:

name: ciencia_datos
channels:
  - conda-forge
  - defaults
dependencies:
  - python=3.13
  - numpy>=2.1.0
  - pandas>=2.2.0
  - scikit-learn>=1.5.0
  - pip:
    - rich>=13.8.0

La Regla de Oro: ¿Cuándo usar venv y cuándo Conda?

Escenario de desarrollo Herramienta ideal Justificación técnica
Desarrollo Web (FastAPI, Django, Flask) venv + pip Arranque instantáneo, contenedores Docker ultra-ligeros y estándar universal sin sobrecarga.
APIs REST, microservicios y CLI venv + pip Dependencias puras de Python, mínimo consumo de disco y despliegue rápido.
Machine Learning y Deep Learning (PyTorch, GPU) Miniconda (Conda) Gestiona drivers CUDA, C++ y aceleración por hardware sin necesidad de instalar SDKs globales en el SO.
Data Science y computación científica pesada Miniconda (Conda) Compatibilidad binaria matemática garantizada a través del canal comunitario conda-forge.
Regla para evitar corromper entornos Conda: Si estás dentro de un entorno Conda, instala siempre primero usando conda install paquete. Utiliza pip install dentro de Conda únicamente si el paquete no existe en Conda ni en el canal conda-forge. Mezclar pip y conda sin orden puede hacer que pip sobrescriba binarios nativos gestionados por Conda.

Puntos clave del Capítulo 2

  • El aislamiento de entornos evita que dos proyectos con versiones opuestas de una misma librería colisionen.
  • PEP 668 bloquea intencionalmente las instalaciones globales de pip para proteger el sistema operativo.
  • Usa venv + pip como primera opción para desarrollo web, scripts y backend.
  • Prefiere Miniconda sobre Anaconda completa para disponer de un entorno ágil sin gigabytes de software innecesario.
  • Conda gestiona binarios C/CUDA a nivel de sistema; su nuevo solver libmamba resuelve dependencias en segundos.
  • Comparte dependencias mediante requirements.txt en pip o environment.yml en Conda.

3 · Visual Studio Code y depuración profesional

Básico ~15 min

Escribir código en un editor de texto plano es suficiente para scripts de diez líneas, pero el desarrollo profesional exige herramientas de autocompletado inteligente, análisis estático y, por encima de todo, un depurador visual. En este capítulo aprenderás a configurar Visual Studio Code para Python 3.13, enlazar tus entornos virtuales y abandonar para siempre el hábito de depurar a ciegas con llamadas manuales a print().

  • Instalar y configurar las extensiones oficiales indispensables de Python en VS Code.
  • Enlazar automáticamente el workspace con el entorno .venv o Conda.
  • Configurar formateo automático y detección de errores de tipado con Pylance y Ruff.
  • Dominar la depuración visual con breakpoints, Step Over (F10) y Step Into (F11).
  • Aprovechar los paneles de inspección de variables, expresiones vigiladas y la consola de depuración en vivo.

Extensiones oficiales indispensables

Abre el panel de extensiones de VS Code (Ctrl + Shift + X o Cmd + Shift + X en macOS) e instala el trío de herramientas mantenido oficialmente por Microsoft y la comunidad:

Extensión Identificador Función principal
Python ms-python.python Soporte general para el lenguaje, ejecución rápida y detección de intérpretes y entornos.
Pylance ms-python.vscode-pylance Servidor de lenguaje de alto rendimiento: autocompletado semántico, importaciones automáticas y análisis de tipos.
Python Debugger ms-python.debugpy Motor de depuración oficial desacoplado para inspección paso a paso y depuración gráfica de tests.
Ruff (o Black) charliermarsh.ruff Formateador y linter extremadamente rápido escrito en Rust; corrige estilo y errores de sintaxis al guardar.

Selección del intérprete: vincular tu entorno

Uno de los fallos más comunes al abrir un proyecto es que VS Code marque librerías como no encontradas (Import "requests" could not be resolved). Esto ocurre porque el editor sigue apuntando al Python global del sistema en lugar del entorno virtual de la carpeta.

  1. Abre la paleta de comandos de VS Code: Ctrl + Shift + P (o Cmd + Shift + P en Mac).
  2. Escribe y selecciona: Python: Select Interpreter.
  3. Elige la opción que apunta a tu carpeta local: Python 3.13.x ('.venv': venv) ./.venv/bin/python (o el nombre de tu entorno Conda).
  4. Comprobación visual: En la barra de estado inferior derecha de VS Code verás ahora el texto 3.13.0 ('.venv').
Activación automática de terminal: Al tener el intérprete seleccionado, cada vez que abras una nueva terminal integrada en VS Code (Ctrl + `), el editor ejecutará automáticamente el script de activación y verás el prefijo (.venv) listo para usar sin escribir source.

Depuración visual paso a paso (Debugging)

Depurar con print() te obliga a modificar el código, ensuciar la salida y volver a borrar las líneas al terminar. El depurador integrado de VS Code permite congelar el programa en el tiempo e inspeccionar toda la memoria:

1. Código de ejemplo para depurar (calculo.py)

def calcular_descuento(precio: float, porcentaje: float) -> float:
    descuento = precio * (porcentaje / 100)
    total = precio - descuento
    return total

def procesar_pedidos(pedidos: list[dict]) -> list[float]:
    resultados = []
    for pedido in pedidos:
        subtotal = pedido["precio"] * pedido["cantidad"]
        total_final = calcular_descuento(subtotal, pedido["descuento"])
        resultados.append(total_final)
    return resultados

carrito = [
    {"producto": "Teclado", "precio": 50.0, "cantidad": 2, "descuento": 10},
    {"producto": "Monitor", "precio": 200.0, "cantidad": 1, "descuento": 15},
]

totales = procesar_pedidos(carrito)
print(f"Totales procesados: {totales}")

2. Colocar un Breakpoint (Punto de interrupción)

Pasa el cursor por el margen izquierdo, justo a la izquierda del número de la línea subtotal = pedido["precio"] * pedido["cantidad"], y haz un clic: aparecerá un círculo rojo sólido. Ese es tu punto de interrupción.

3. Iniciar la sesión de depuración

Presiona la tecla F5 (o ve a la barra lateral izquierda al icono de *Run & Debug* y pulsa *Run and Debug*). Selecciona "Python Debugger: Python File". El programa arrancará y se detendrá exactamente en la línea del círculo rojo, resaltándola en color amarillo.

4. La barra de control de depuración

Botón Atajo Acción exacta
Continue F5 Reanuda la ejecución continua hasta alcanzar el siguiente breakpoint o el final del programa.
Step Over F10 Ejecuta la línea resaltada actual y avanza a la siguiente línea del mismo nivel, sin entrar en funciones.
Step Into F11 Si la línea llama a una función (como calcular_descuento), entra dentro de ella para ver su lógica interna.
Step Out Shift + F11 Termina la ejecución de la función actual y vuelve a la línea del llamador.
Restart Ctrl + Shift + F5 Reinicia el script desde cero manteniendo los mismos breakpoints activos.
Stop Shift + F5 Cancela inmediatamente el proceso y cierra la sesión de depuración.

Los 4 paneles de inspección en vivo

Mientras el programa permanece pausado en un breakpoint, el panel lateral de depuración te ofrece visibilidad absoluta:

  • Variables (Locales y Globales): Muestra todas las variables en el ámbito actual con sus valores y tipos en tiempo real. Si pulsas F10, verás cómo los valores cambian de color indicando su actualización.
  • Watch (Expresiones vigiladas): Haz clic en el botón + y escribe cualquier expresión de Python (por ejemplo, pedido["precio"] > 100 o len(resultados)). Se evaluará dinámicamente en cada paso que des.
  • Call Stack (Pila de llamadas): Muestra la jerarquía de funciones activas. Te permite entender exactamente qué función llamó a cuál antes de llegar al punto actual.
  • Debug Console (Consola interactiva de depuración): En la pestaña inferior Debug Console, puedes escribir código Python al vuelo y presionar Enter: puedes consultar el estado de variables, invocar métodos e incluso modificar valores en memoria antes de reanudar el programa.

Configuración profesional: .vscode/launch.json

Si tu aplicación requiere argumentos de línea de comandos o variables de entorno personalizadas, puedes crear un archivo .vscode/launch.json en la raíz de tu proyecto:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Python: Script actual con argumentos",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "console": "integratedTerminal",
      "args": ["--modo", "pruebas", "--puerto", "8000"],
      "env": {
        "ENTORNO": "desarrollo",
        "DEBUG": "true"
      }
    }
  ]
}

Puntos clave del Capítulo 3

  • Instala las extensiones Python, Pylance y Python Debugger de Microsoft.
  • Usa siempre Ctrl + Shift + PPython: Select Interpreter para enlazar tu .venv.
  • Los Breakpoints pausan la ejecución en una línea concreta sin alterar el código fuente.
  • Con F10 avanzas paso a paso en el mismo bloque y con F11 entras al interior de las funciones.
  • La Debug Console permite ejecutar pruebas y consultar variables en caliente mientras el programa está en pausa.

4 · Sintaxis básica y tipos primitivos

Básico ~15 min

La filosofía de Python prioriza la legibilidad, la elegancia y la expresividad del código (como dicta el Zen de Python: «Explícito es mejor que implícito»). En este capítulo revisaremos los cimientos firmes del lenguaje: el modelo de tipado dinámico y fuerte, los tipos primitivos fundamentales, los operadores aritmético-lógicos y el formateo avanzado de texto.

  • Comprender por qué Python es dinámico pero fuertemente tipado (sin coerción silenciosa).
  • Dominar las cuatro reglas esenciales de sintaxis: sangría de 4 espacios, dos puntos y sensibilidad a mayúsculas.
  • Trabajar con enteros de precisión arbitraria, punto flotante, booleanos y el valor centinela None.
  • Aplicar operadores aritméticos, comparaciones encadenadas y evaluación de cortocircuito.
  • Dominar el rebanado de texto (slicing) y las f-strings modernas con especificadores de formato.

Las reglas doradas de la sintaxis

A diferencia de lenguajes como C, Java o PHP, Python prescinde de llaves ({ }) y puntos y coma (;) para delimitar bloques. La estructura visual es la estructura sintáctica:

  1. Indentación obligatoria: Cada nivel de bloque lógico debe sangrarse exactamente con 4 espacios (evita mezclar tabuladores).
  2. Dos puntos delimitadores (:): Toda sentencia que abre un bloque condicional, bucle, función o clase termina con dos puntos.
  3. Sensibilidad a mayúsculas: Los identificadores usuario, Usuario y USUARIO son tres variables totalmente distintas.
  4. Convención de nombres (PEP 8): Las variables y funciones utilizan snake_case (ej. total_ventas), las constantes SCREAMING_SNAKE_CASE (ej. MAX_INTENTOS) y las clases PascalCase.

Tipado dinámico vs fuertemente tipado

En Python, las variables son etiquetas que apuntan a objetos en memoria, no cajas con tipo rígido. Una variable puede apuntar hoy a un entero y mañana a una cadena (tipado dinámico). Sin embargo, Python es fuertemente tipado: nunca realizará conversiones mágicas o coerciones silenciosas entre tipos incompatibles:

# Tipado dinámico: la variable puede reasignarse a otro tipo
edad = 25              # edad apunta a un objeto int
print(type(edad))      # <class 'int'>

edad = "veinticinco"   # ahora apunta a un objeto str
print(type(edad))      # <class 'str'>

# Tipado fuerte: NO hay coerción silenciosa
numero = 10
texto = "5"

# Esto provocará un TypeError inmediato (a diferencia de JavaScript):
# resultado = numero + texto  --> TypeError: unsupported operand type(s)

# Conversión explícita obligatoria (casting):
resultado_suma = numero + int(texto)    # 15
resultado_union = str(numero) + texto   # "105"
print(f"Suma: {resultado_suma} | Unión: {resultado_union}")
Suma: 15 | Unión: 105

Tipos primitivos esenciales

Tipo Clase Ejemplo literal Detalle técnico
int Entero 42, -15, 1_000_000 Precisión arbitraria: nunca desborda la memoria de 32 o 64 bits.
float Flotante 3.1416, 2.5e-3 Estándar IEEE 754 de 64 bits (doble precisión).
bool Booleano True, False Subclase formal de int (True == 1, False == 0).
str Cadena de texto "Hola", 'Python' Secuencia inmutable de caracteres Unicode UTF-8.
None Nulo / Vacío None Objeto singleton de tipo NoneType para indicar ausencia de valor.

Operadores aritméticos y lógicos

Python incluye operadores matemáticos de gran precisión y soporte para expresiones lógicas elegantes:

a = 14
b = 4

print(a + b)   # 18      (suma)
print(a - b)   # 10      (resta)
print(a * b)   # 56      (multiplicación)
print(a / b)   # 3.5     (división real de punto flotante)
print(a // b)  # 3       (división de piso entera)
print(a % b)   # 2       (módulo / residuo euclidiano)
print(a ** b)  # 38416   (potencia 14^4)

# Comparaciones encadenadas idiomáticas:
edad = 28
if 18 <= edad < 65:
    print("Edad laboral activa")

# Operadores lógicos con cortocircuito:
tiene_permiso = True
es_admin = False

if es_admin or tiene_permiso:
    print("Acceso concedido al panel")
Edad laboral activa Acceso concedido al panel

Cadenas de texto: métodos, slicing y f-strings

Las cadenas (str) son secuencias inmutables. Todo método que transforma una cadena retorna un nuevo objeto str sin modificar el original.

1. Métodos esenciales de transformación

mensaje = "  Aprende Python con WebCode  "

# Limpieza de espacios en extremos
limpio = mensaje.strip()

# Búsquedas y reemplazos
print(limpio.lower())                    # "aprende python con webcode"
print(limpio.upper())                    # "APRENDE PYTHON CON WEBCODE"
print(limpio.replace("WebCode", "Dev"))  # "Aprende Python con Dev"
print(limpio.startswith("Aprende"))      # True
print(limpio.endswith("Code"))           # True

# División y unión de palabras
palabras = limpio.split(" ")             # ['Aprende', 'Python', 'con', 'WebCode']
reunido = "-".join(palabras)             # "Aprende-Python-con-WebCode"
print(reunido)
Aprende-Python-con-WebCode

2. Rebanado de cadenas (Slicing): [inicio:fin:paso]

El operador de rebanado extrae subcadenas con límites semiabiertos [inicio, fin), donde el índice de fin queda excluido:

texto = "PYTHON313"

print(texto[0:6])    # "PYTHON"   (del índice 0 al 5)
print(texto[:6])     # "PYTHON"   (desde el principio)
print(texto[6:])     # "313"      (desde el 6 hasta el final)
print(texto[-3:])    # "313"      (los últimos 3 caracteres con índices negativos)
print(texto[::2])    # "PTO33"    (de dos en dos pasos)
print(texto[::-1])   # "313NOHTYP" (truco idiomático para invertir la cadena)
PYTHON 313 313 313NOHTYP

3. F-Strings modernas y especificadores de formato

Las cadenas formateadas (f"...") evalúan expresiones en tiempo de ejecución. Permiten alinear texto, formatear decimales y depurar al instante:

precio = 1250.758
tasa = 0.18
usuario = "percy"

# Formateo con dos decimales y separador de miles
print(f"Subtotal: S/. {precio:,.2f}")

# Formateo de porcentaje
print(f"Impuesto IGV: {tasa:.1%}")

# Depuración rápida con el especificador = (Python 3.8+)
print(f"Depuración: {usuario=}, {precio=}")
Subtotal: S/. 1,250.76 Impuesto IGV: 18.0% Depuración: usuario='percy', precio=1250.758

Puntos clave del Capítulo 4

  • La indentación de 4 espacios es obligatoria y define los bloques de código sin llaves ni puntos y coma.
  • Python es dinámico pero fuertemente tipado: no mezcla números y textos automáticamente sin casting.
  • Los enteros int poseen precisión infinita y no sufren de desbordamientos numéricos.
  • El operador de división entera // redondea hacia el suelo y % calcula el módulo.
  • El rebanado [inicio:fin:paso] permite extraer fragmentos e invertir cadenas con [::-1].
  • Usa f-strings con {expresion=} para depuración y especificadores como :.2f para moneda.

5 · Control de flujo e iteración moderna

Básico ~15 min

El control de flujo dicta cómo toma decisiones un programa y cómo recorre conjuntos de datos. En Python, las bifurcaciones y los bucles son elegantes, expresivos y seguros: aprenderás a estructurar condicionales, dominarás los bucles for con iteradores nativos, descubrirás el patrón idiomático de la cláusula else en bucles y aplicarás coincidencia estructural con match-case.

  • Dominar las bifurcaciones con if, elif, else y el operador ternario.
  • Utilizar for con range, enumerate y zip(strict=True).
  • Gestionar ciclos con while, interrupciones break y saltos continue.
  • Aprender el patrón de búsqueda idiomática con for...else sin banderas booleanas.
  • Aplicar el potente match-case con guardas condicionales y desestructuración.

Condicionales y operador ternario

Las sentencias if, elif y else evalúan expresiones booleanas secuencialmente. En Python cualquier valor vacío (como listas vacías [], cadenas vacías "", el número 0 o None) se evalúa como falso (*falsy*):

calificacion = 85

if calificacion >= 90:
    nivel = "Sobresaliente"
elif calificacion >= 80:
    nivel = "Notable"
elif calificacion >= 70:
    nivel = "Aprobado"
else:
    nivel = "Reprobado"

print(f"Resultado académico: {nivel}")

# Expresión ternaria (condicional en una sola línea):
# sintaxis: [valor_si_true] if [condicion] else [valor_si_false]
estado = "Promovido" if calificacion >= 70 else "Repite curso"
print(f"Estado de matrícula: {estado}")
Resultado académico: Notable Estado de matrícula: Promovido

Bucles modernos: for, range, enumerate y zip

El bucle for en Python es un bucle for-each: no requiere controlar manualmente índices numéricos, sino que recorre directamente los elementos de cualquier secuencia o colección iterable.

1. Secuencias con range(inicio, fin, paso)

# range genera números en demanda con memoria O(1)
for i in range(1, 6):
    print(f"Turno #{i}", end=" ")
print()
Turno #1 Turno #2 Turno #3 Turno #4 Turno #5

2. enumerate: índice y valor sin variables contadoras

Evita crear contadores manuales como i = 0; i += 1. La función enumerate() genera tuplas con el índice y el elemento en cada iteración:

lenguajes = ["Python", "Dart", "Kotlin", "TypeScript"]

for pos, item in enumerate(lenguajes, start=1):
    print(f"{pos}. {item}")
1. Python 2. Dart 3. Kotlin 4. TypeScript

3. zip con strict=True (PEP 618): iteración en paralelo

Para recorrer múltiples colecciones en simultáneo de forma emparejada, utiliza zip(). En Python 3.10+ se recomienda el parámetro strict=True para alertar con un error si las listas tienen longitudes dispares, previniendo pérdidas silenciosas de datos:

estudiantes = ["Ana", "Carlos", "Percy"]
promedios = [18.5, 15.0, 19.2]

for alumno, nota in zip(estudiantes, promedios, strict=True):
    print(f"Estudiante: {alumno:8} | Promedio: {nota:.1f}")
Estudiante: Ana | Promedio: 18.5 Estudiante: Carlos | Promedio: 15.0 Estudiante: Percy | Promedio: 19.2

La cláusula for...else (el patrón nobreak)

Una de las características más elegantes y peor comprendidas de Python es que los bucles for y while admiten una cláusula else. El bloque else se ejecuta únicamente si el bucle concluyó de manera natural (es decir, si NO fue interrumpido prematuramente por un break). Esto elimina la necesidad de variables de bandera auxiliares:

usuarios = ["carlos_dev", "marina_ui", "percy_root"]
buscado = "admin"

# Patrón idiomático for...else:
for user in usuarios:
    if user == buscado:
        print(f"Usuario {buscado} encontrado en el sistema.")
        break
else:
    # Se ejecuta SOLO si el bucle terminó sin encontrar coincidencias
    print(f"Alerta: el usuario '{buscado}' no existe en el registro.")
Alerta: el usuario 'admin' no existe en el registro.

Pattern Matching estructural: match-case

Introducido en Python 3.10 (PEP 634-636), match-case es mucho más que un switch tradicional. Permite evaluar valores literales, alternativas lógicas (|), guardas condicionales (if) y desestructurar secuencias complejas:

def procesar_comando(comando: list[str]) -> str:
    match comando:
        case ["iniciar"]:
            return "Iniciando servicio en puerto 8000..."
        case ["detener", ("forzado" | "inmediato")]:
            return "Detención de emergencia activada."
        case ["detener"]:
            return "Detención programada con cierre limpio."
        case ["mover", int(x), int(y)] if x >= 0 and y >= 0:
            return f"Moviendo cursor a coordenadas positivas: ({x}, {y})"
        case ["mover", _, _]:
            return "Error: las coordenadas de movimiento deben ser positivas."
        case _:
            return f"Comando no reconocido: {comando}"

print(procesar_comando(["iniciar"]))
print(procesar_comando(["detener", "forzado"]))
print(procesar_comando(["mover", 120, 45]))
print(procesar_comando(["reiniciar"]))
Iniciando servicio en puerto 8000... Detención de emergencia activada. Moviendo cursor a coordenadas positivas: (120, 45) Comando no reconocido: ['reiniciar']

Puntos clave del Capítulo 5

  • El operador ternario x if cond else y simplifica asignaciones condicionales a una sola línea.
  • Usa siempre enumerate() para iterar obteniendo el índice y zip(strict=True) para sincronizar listas.
  • La cláusula for...else ejecuta su bloque cuando el bucle termina sin disparar ningún break.
  • match-case permite desestructurar secuencias, combinar alternativas con tuberías | y agregar guardas condicionales.

6 · Colecciones nativas y mutabilidad

Intermedio ~20 min

Casi todo programa en el mundo real consiste en transformar colecciones de datos. Python cuenta con cuatro estructuras de datos nativas optimizadas en C: listas, tuplas, diccionarios y conjuntos. En este capítulo dominarás sus diferencias arquitectónicas, aprenderás a evitar los peligros de la mutabilidad y las copias superficiales, escribirás comprensiones idiomáticas y resolverás tu primer mini-reto práctico.

  • Dominar las 4 colecciones nativas: list, tuple, dict y set.
  • Comprender la diferencia entre identidad de punteros y valor en memoria (is vs ==).
  • Evitar el fallo clásico de referencias compartidas mediante copia superficial (copy) y profunda (deepcopy).
  • Escribir comprensiones concisas y eficientes para listas, diccionarios y conjuntos.
  • Poner a prueba tus habilidades resolviendo el Mini-reto 1 de análisis de catálogo.

Las 4 colecciones nativas esenciales

1. Listas (list): secuencias dinámicas y mutables

Las listas almacenan colecciones ordenadas de elementos heterogéneos. Al ser mutables, su contenido puede alterarse en su lugar:

frutas = ["manzana", "plátano"]

frutas.append("naranja")            # Inserción al final: O(1) amortizado
frutas.extend(["uva", "kiwi"])      # Une múltiples elementos
frutas.insert(1, "arándano")        # Inserción en índice específico: O(n)
ultimo = frutas.pop()               # Extrae y retorna el último elemento ("kiwi")
frutas.sort(reverse=True)           # Ordenamiento in-place con algoritmo Powersort

print(f"Lista final: {frutas} | Extraído: {ultimo}")
Lista final: ['plátano', 'naranja', 'manzana', 'uva', 'arándano'] | Extraído: kiwi

2. Tuplas (tuple): inmutables y livianas

Las tuplas se definen con paréntesis ( ) o por separación de comas. Una vez creadas, no pueden modificarse. Tienen una huella de memoria menor que las listas y pueden usarse como claves de diccionarios:

coordenadas = (12.043, -77.028)

# Desempaquetado con operador estrella (PEP 3132):
registro = ("Servidor-Web", 8080, "ACTIVO", "Ubuntu", "24.04")
host, puerto, estado, *sistema = registro

print(f"Host: {host}:{puerto} | Estado: {estado} | Detalles OS: {sistema}")
Host: Servidor-Web:8080 | Estado: ACTIVO | Detalles OS: ['Ubuntu', '24.04']

3. Diccionarios (dict): tablas hash compactas clave-valor

Desde Python 3.7, los diccionarios garantizan formalmente el orden de inserción de las claves. Ofrecen acceso ultra-rápido en tiempo constante $O(1)$:

usuario = {"nombre": "Percy", "rol": "Admin", "nivel": 5}

# Acceso seguro con .get() para evitar KeyError si la clave no existe
email = usuario.get("email", "sin_correo@webcode.net.pe")

# Unión de diccionarios con el operador | (PEP 584 / Python 3.9+)
nuevos_datos = {"nivel": 6, "ciudad": "Lima"}
usuario_actualizado = usuario | nuevos_datos

print(f"Actualizado: {usuario_actualizado}")
Actualizado: {'nombre': 'Percy', 'rol': 'Admin', 'nivel': 6, 'ciudad': 'Lima'}

4. Conjuntos (set): valores únicos y álgebra de conjuntos

Los conjuntos no permiten duplicados y ofrecen pruebas de pertenencia x in conjunto a la velocidad del hash ($O(1)$ frente a $O(n)$ en listas):

backend = {"Python", "PostgreSQL", "Linux", "Docker"}
frontend = {"HTML", "CSS", "JavaScript", "Docker"}

# Álgebra de conjuntos nativa:
ambos = backend & frontend       # Intersección (elementos comunes)
todos = backend | frontend       # Unión total sin duplicados
solo_backend = backend - frontend # Diferencia

print(f"Compartido: {ambos}")
print(f"Solo Backend: {solo_backend}")
Compartido: {'Docker'} Solo Backend: {'PostgreSQL', 'Linux', 'Python'}

Tabla comparativa de colecciones

Colección Literal Mutable Ordenada Duplicados Búsqueda (in)
list [1, 2, 3] $O(n)$ Lineal
tuple (1, 2, 3) No $O(n)$ Lineal
dict {"k": "v"} Sí (por inserción) Claves no, valores sí $O(1)$ Constante
set {1, 2, 3} No garantizado No (únicos) $O(1)$ Constante

Mutabilidad y copias: Shallow vs Deep Copy

La asignación b = a en Python no clona los datos, sino que crea una segunda etiqueta que apunta exactamente al mismo objeto en memoria:

import copy

original = [1, [2, 3], 4]

# 1. Copia superficial (Shallow copy)
copia_sup = original.copy()
copia_sup[0] = 99
copia_sup[1].append(999) # ¡Altera la sublista compartida!

print(f"Original tras shallow: {original}")

# 2. Copia profunda (Deep copy) con módulo copy
original2 = [1, [2, 3], 4]
copia_prof = copy.deepcopy(original2)
copia_prof[1].append(888) # Aislado al 100%

print(f"Original tras deep:    {original2}")
print(f"Copia profunda:        {copia_prof}")
Original tras shallow: [1, [2, 3, 999], 4] Original tras deep: [1, [2, 3], 4] Copia profunda: [1, [2, 3, 888], 4]
El error clásico de matrices: Nunca crees matrices con repetición superficial: m = [[0] * 3] * 3 crea tres filas que apuntan todas a la misma lista física. La forma correcta es mediante comprensiones independientes: m = [[0] * 3 for _ in range(3)].

Comprensiones (Comprehensions)

Las comprensiones son la forma idiomática de filtrar y transformar datos en una sola línea elegante:

numeros = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# List comprehension: cuadrados de números pares
pares_al_cuadrado = [n**2 for n in numeros if n % 2 == 0]
print(f"Pares al cuadrado: {pares_al_cuadrado}")

# Dict comprehension: mapear nombres a su longitud
ciudades = ["Lima", "Arequipa", "Cusco", "Trujillo"]
longitudes = {ciudad: len(ciudad) for ciudad in ciudades}
print(f"Longitudes: {longitudes}")

# Set comprehension: deduplicar palabras convirtiendo a minúsculas
etiquetas = ["Python", "python", "DJANGO", "Django", "FastAPI"]
unicas = {tag.lower() for tag in etiquetas}
print(f"Etiquetas únicas: {unicas}")
Pares al cuadrado: [4, 16, 36, 64, 100] Longitudes: {'Lima': 4, 'Arequipa': 8, 'Cusco': 5, 'Trujillo': 8} Etiquetas únicas: {'python', 'django', 'fastapi'}

Mini-reto 1: Analizador de ventas de catálogo

Consigna: Tienes una lista de transacciones con posibles categorías duplicadas y montos de venta. Debes escribir un script que:

  1. Deduplique y liste las categorías participantes usando un set.
  2. Calcule el total acumulado de ventas por categoría usando un dict.
  3. Genere un nuevo diccionario filtrado que contenga únicamente las categorías con ventas mayores a S/. 200 usando una dict comprehension.
Ver solución del Mini-reto 1
ventas = [
    {"categoria": "Tecnología", "monto": 150.0},
    {"categoria": "Libros", "monto": 45.0},
    {"categoria": "Tecnología", "monto": 120.0},
    {"categoria": "Hogar", "monto": 80.0},
    {"categoria": "Libros", "monto": 35.0},
    {"categoria": "Hogar", "monto": 180.0},
]

# 1. Categorías únicas
categorias_unicas = {v["categoria"] for v in ventas}

# 2. Total por categoría
totales = {}
for v in ventas:
    cat = v["categoria"]
    totales[cat] = totales.get(cat, 0.0) + v["monto"]

# 3. Filtrar categorías destacadas (> 200) con dict comprehension
destacadas = {cat: monto for cat, monto in totales.items() if monto > 200.0}

print(f"Categorías presentes: {categorias_unicas}")
print(f"Ventas totales:       {totales}")
print(f"Categorías TOP (>200): {destacadas}")
Categorías presentes: {'Hogar', 'Tecnología', 'Libros'} Ventas totales: {'Tecnología': 270.0, 'Libros': 80.0, 'Hogar': 260.0} Categorías TOP (>200): {'Tecnología': 270.0, 'Hogar': 260.0}

Puntos clave del Capítulo 6

  • Usa listas para colecciones ordenadas mutables y tuplas para datos inmutables y fijos.
  • Los diccionarios ofrecen búsqueda en $O(1)$ y se fusionan limpiamente con el operador |.
  • Los conjuntos erradican duplicados y permiten operaciones de intersección (&) y unión (|).
  • Recuerda: la asignación simple copia el puntero, no el objeto. Usa copy.deepcopy() para estructuras anidadas.
  • Las comprensiones son más rápidas y legibles que los bucles tradicionales con .append().

7 · Funciones y programación funcional

Básico ~15 min

Las funciones son los bloques fundamentales de reutilización y abstracción en Python. Al ser ciudadanos de primera clase (*first-class citizens*), las funciones pueden asignarse a variables, pasarse como argumentos y retornar de otras funciones. En este capítulo dominarás el paso de parámetros por referencia de objeto, evitarás la trampa mortal de los valores por defecto mutables, comprenderás la regla de ámbitos LEGB y aplicarás técnicas funcionales como lambda, map, filter y reduce.

  • Declarar funciones idiomáticas con parámetros posicionales, nombrados y valores por defecto seguros.
  • Evitar el error de principiante de argumentos mutables por defecto usando el centinela None.
  • Dominar el empaquetado y desempaquetado flexible con *args y **kwargs.
  • Entender la resolución de variables con la jerarquía LEGB (global y nonlocal).
  • Aplicar funciones puras con map, filter y functools.reduce.
  • Resolver el Mini-reto 2 de cálculo funcional de métricas comerciales.

Definición y paso por referencia de objeto

En Python, todos los argumentos se transmiten mediante paso por asignación de objeto (*pass-by-object-reference*): el parámetro recibe una referencia al mismo objeto en memoria:

  • Si pasas un objeto inmutable (int, float, str, tuple), cualquier modificación dentro de la función reasigna la variable local a un nuevo objeto sin alterar la variable original externa.
  • Si pasas un objeto mutable (list, dict, set) y lo alteras in-place (ej. .append()), el cambio se reflejará inmediatamente fuera de la función.
def modificar_datos(numero: int, lista: list) -> None:
    numero = numero + 10     # Reasigna variable local (el int original no cambia)
    lista.append("NUEVO")    # Muta el objeto en memoria directamente

valor = 5
mi_lista = ["A", "B"]

modificar_datos(valor, mi_lista)
print(f"Valor int externo:   {valor}")     # Sigue siendo 5
print(f"Lista mutada externa: {mi_lista}")  # ['A', 'B', 'NUEVO']
Valor int externo: 5 Lista mutada externa: ['A', 'B', 'NUEVO']

La trampa mortal de los valores por defecto mutables

Los valores por defecto de una función se evalúan una única vez cuando el archivo es cargado por el intérprete, no cada vez que la función es invocada:

# ERROR COMÚN PELIGROSO:
def agregar_tarea_mal(tarea: str, lista: list = []) -> list:
    lista.append(tarea)
    return lista

print(agregar_tarea_mal("Comprar pan"))  # ['Comprar pan']
print(agregar_tarea_mal("Lavar auto"))   # ['Comprar pan', 'Lavar auto'] <-- ¡Memoria compartida!

# SOLUCIÓN CANÓNICA CON CENTINELA None:
def agregar_tarea_bien(tarea: str, lista: list | None = None) -> list:
    if lista is None:
        lista = []  # Se crea una lista nueva e independiente en cada llamada
    lista.append(tarea)
    return lista

print(agregar_tarea_bien("Comprar pan")) # ['Comprar pan']
print(agregar_tarea_bien("Lavar auto"))  # ['Lavar auto']
['Comprar pan'] ['Comprar pan', 'Lavar auto'] ['Comprar pan'] ['Lavar auto']

Argumentos variables: *args y **kwargs

Permiten que una función reciba cualquier cantidad de parámetros posicionales o nombrados:

def registrar_auditoria(evento: str, *args, **kwargs) -> None:
    print(f"Evento principal: {evento}")
    print(f"Detalles posicionales (*args -> tuple): {args}")
    print(f"Metadatos nombrados (**kwargs -> dict): {kwargs}")

registrar_auditoria(
    "INICIO_SESION",
    "ip:192.168.1.10",
    "navegador:Chrome",
    usuario="percy",
    exitoso=True
)
Evento principal: INICIO_SESION Detalles posicionales (*args -> tuple): ('ip:192.168.1.10', 'navegador:Chrome') Metadatos nombrados (**kwargs -> dict): {'usuario': 'percy', 'exitoso': True}

Ámbitos de variables y la regla LEGB

Cuando Python busca el valor de una variable, consulta estrictamente cuatro ámbitos en orden:

  1. Local (L): Variables definidas dentro de la función actual.
  2. Enclosing (E): Variables en funciones contenedoras anidadas (closures).
  3. Global (G): Variables definidas en el nivel superior del módulo/archivo.
  4. Built-in (B): Funciones nativas del sistema (len, range, print).
def creador_contador(inicial: int = 0):
    conteo = inicial

    def incrementar() -> int:
        nonlocal conteo  # Permite modificar la variable del ámbito Enclosing
        conteo += 1
        return conteo

    return incrementar

contador = creador_contador(10)
print(contador())  # 11
print(contador())  # 12
11 12

Programación funcional: lambdas, map, filter y reduce

Python combina el paradigma estructurado y orientado a objetos con herramientas funcionales concisas:

from functools import reduce

precios = [120.0, 45.0, 230.0, 15.0, 80.0]

# 1. Función lambda: función anónima de una sola línea
# 2. filter: retiene solo los elementos que cumplen una condición booleana
caros = list(filter(lambda p: p >= 100.0, precios))
print(f"Precios >= 100: {caros}")

# 3. map: transforma cada elemento aplicando una función
con_descuento = list(map(lambda p: round(p * 0.90, 2), caros))
print(f"Con 10% descuento: {con_descuento}")

# 4. reduce: acumula iterativamente todos los elementos a un único valor
total_acumulado = reduce(lambda acumulador, p: acumulador + p, con_descuento, 0.0)
print(f"Total acumulado: S/. {total_acumulado:.2f}")
Precios >= 100: [120.0, 230.0] Con 10% descuento: [108.0, 207.0] Total acumulado: S/. 315.00

Recursividad: el caso base

Una función recursiva se invoca a sí misma para resolver subproblemas de menor tamaño. Toda recursión debe tener obligatoriamente un caso base de detención para evitar desbordar la pila de ejecución (RecursionError):

def factorial(n: int) -> int:
    if n <= 1:
        return 1  # Caso base de detención
    return n * factorial(n - 1)  # Paso recursivo

print(f"Factorial de 5: {factorial(5)}")
Factorial de 5: 120

Mini-reto 2: Filtro y agregación funcional

Consigna: Se te entrega una lista de pedidos donde cada pedido es un diccionario con {"cliente": str, "total": float, "aprobado": bool}. Utilizando exclusivamente herramientas funcionales (filter, map y reduce de functools):

  1. Filtra los pedidos que tengan aprobado: True.
  2. Extrae únicamente el monto total de cada pedido aprobado.
  3. Calcula la suma total de los pedidos aprobados usando reduce.
Ver solución del Mini-reto 2
from functools import reduce

ordenes = [
    {"cliente": "Carlos", "total": 150.0, "aprobado": True},
    {"cliente": "Marina", "total": 85.0,  "aprobado": False},
    {"cliente": "Percy",  "total": 320.0, "aprobado": True},
    {"cliente": "Diana",  "total": 45.0,  "aprobado": True},
]

# 1. Filtrar aprobados
aprobados = filter(lambda o: o["aprobado"], ordenes)

# 2. Mapear montos
montos = map(lambda o: o["total"], aprobados)

# 3. Reducir a suma total
gran_total = reduce(lambda acum, m: acum + m, montos, 0.0)

print(f"Total recaudado de pedidos aprobados: S/. {gran_total:.2f}")
Total recaudado de pedidos aprobados: S/. 515.00

Puntos clave del Capítulo 7

  • Las funciones son ciudadanos de primera clase y se transmiten por asignación de referencia a objeto.
  • Nunca uses valores mutables por defecto ([] o {}); usa None y crea el objeto dentro.
  • *args empaqueta argumentos posicionales sobrantes en una tupla; **kwargs en un diccionario.
  • La regla LEGB rige la visibilidad; usa nonlocal para modificar variables de closures.
  • map y filter procesan iterables de forma perezosa y reduce colapsa datos a un acumulado.

8 · POO, encapsulación y Dataclasses

Intermedio ~20 min

La Programación Orientada a Objetos (POO) en Python destaca por su simplicidad y elegancia: no requiere palabras clave como new ni declaraciones barrocas de visibilidad. En este capítulo aprenderás a modelar entidades con clases limpias, aplicarás encapsulamiento profesional mediante el decorador @property, comprenderás los métodos mágicos dunder y aprovecharás el poder de las @dataclass modernas.

  • Modelar clases y entender por qué el parámetro self es explícito.
  • Dominar los métodos mágicos indispensables: __init__, __repr__ y __str__.
  • Aplicar encapsulamiento seguro con @property y validaciones en setters.
  • Diferenciar métodos de instancia, @classmethod (constructores alternativos) y @staticmethod.
  • Reducir código repetitivo con @dataclass(slots=True, frozen=True).
  • Resolver el Mini-reto 3 creando un modelo de cuenta bancaria blindado.

Clases, instancias y el argumento self

Una clase es una plantilla que define el estado (atributos) y el comportamiento (métodos) de sus objetos. En Python, el primer argumento de cualquier método de instancia siempre debe llamarse self: es la referencia explícita a la instancia concreta que invoca el método.

class Medico:
    def __init__(self, nombre: str, especialidad: str, colegiatura: str):
        # Atributos de instancia
        self.nombre = nombre
        self.especialidad = especialidad
        self.colegiatura = colegiatura

    def presentarse(self) -> str:
        return f"Dr(a). {self.nombre} · Esp: {self.especialidad} (CMP: {self.colegiatura})"

# Creación de instancias (sin usar palabra clave 'new')
doc1 = Medico("Carlos Mendoza", "Cardiología", "45210")
print(doc1.presentarse())
Dr(a). Carlos Mendoza · Esp: Cardiología (CMP: 45210)

Métodos dunder indispensables: __repr__ y __str__

Los métodos que empiezan y terminan con doble guion bajo (*double underscore* o *dunder*) conectan tus clases con las funciones nativas del lenguaje:

  • __str__(): Representación textual legible y amigable, utilizada por print() y str().
  • __repr__(): Representación técnica e inequívoca, pensada para desarrolladores y sesiones de depuración en VS Code.
class Paciente:
    def __init__(self, id_paciente: int, nombre: str, dni: str):
        self.id_paciente = id_paciente
        self.nombre = nombre
        self.dni = dni

    def __str__(self) -> str:
        return f"{self.nombre} (DNI: {self.dni})"

    def __repr__(self) -> str:
        return f"Paciente(id={self.id_paciente!r}, nombre={self.nombre!r}, dni={self.dni!r})"

pac = Paciente(101, "Ana Torres", "44556677")
print(str(pac))    # Vista amigable: Ana Torres (DNI: 44556677)
print(repr(pac))   # Vista técnica: Paciente(id=101, nombre='Ana Torres', dni='44556677')
Ana Torres (DNI: 44556677) Paciente(id=101, nombre='Ana Torres', dni='44556677')

Encapsulación elegante con @property

Python adopta la filosofía de "Adultos responsables que consienten": por convención, los atributos que inician con un guion bajo (_precio) se consideran protegidos y no deben modificarse directamente desde fuera. Para ofrecer acceso controlado y validaciones sin obligar a escribir métodos como get_precio() o set_precio(), se utiliza el decorador @property:

class Producto:
    def __init__(self, nombre: str, precio: float):
        self.nombre = nombre
        self._precio = precio  # Atributo protegido

    @property
    def precio(self) -> float:
        """Getter: se accede como un atributo regular (prod.precio)."""
        return self._precio

    @precio.setter
    def precio(self, nuevo_valor: float) -> None:
        """Setter: valida antes de asignar (prod.precio = 150)."""
        if nuevo_valor < 0:
            raise ValueError("El precio de un producto no puede ser negativo.")
        self._precio = round(nuevo_valor, 2)

prod = Producto("Laptop Pro", 4200.50)
print(f"Precio inicial: S/. {prod.precio:.2f}")

prod.precio = 3999.90  # Invoca al setter de forma transparente
print(f"Precio con descuento: S/. {prod.precio:.2f}")

# prod.precio = -100  --> Provoca ValueError inmediato con mensaje claro
Precio inicial: S/. 4200.50 Precio con descuento: S/. 3999.90

Métodos de clase (@classmethod) y estáticos (@staticmethod)

No todos los métodos operan sobre una instancia individual:

class CitaMedica:
    COSTO_BASE = 80.0

    def __init__(self, paciente: str, especialidad: str, costo: float):
        self.paciente = paciente
        self.especialidad = especialidad
        self.costo = costo

    @classmethod
    def crear_cita_general(cls, paciente: str) -> "CitaMedica":
        """Constructor alternativo que crea una cita con valores predefinidos."""
        return cls(paciente, "Medicina General", cls.COSTO_BASE)

    @staticmethod
    def es_horario_habil(hora: int) -> bool:
        """Función utilitaria pura sin acceso a la instancia ni a la clase."""
        return 8 <= hora < 18

cita = CitaMedica.crear_cita_general("Percy Almeyda")
print(f"Cita creada: {cita.paciente} - {cita.especialidad} (S/. {cita.costo})")
print(f"¿Atención a las 14h válida? {CitaMedica.es_horario_habil(14)}")
Cita creada: Percy Almeyda - Medicina General (S/. 80.0) ¿Atención a las 14h válida? True

Clases modernas: @dataclass

Introducidas en Python 3.7 y optimizadas en 3.13, las dataclasses generan automáticamente los métodos __init__, __repr__ y __eq__ a partir de atributos con anotaciones de tipo:

from dataclasses import dataclass

@dataclass(slots=True, frozen=True)
class Medicamento:
    nombre: str
    dosis_mg: int
    requiere_receta: bool = False

med1 = Medicamento("Paracetamol", 500)
med2 = Medicamento("Paracetamol", 500)

print(med1)              # Medicamento(nombre='Paracetamol', dosis_mg=500, requiere_receta=False)
print(med1 == med2)      # True (compara por valores, no por punteros)

# Gracias a slots=True: menor huella de memoria RAM sin overhead de __dict__
# Gracias a frozen=True: objeto inmutable y seguro para usar como clave de diccionario
Medicamento(nombre='Paracetamol', dosis_mg=500, requiere_receta=False) True

Mini-reto 3: Cuenta Bancaria con encapsulamiento

Consigna: Modela una clase CuentaBancaria que reciba un titular y un saldo inicial. Debes cumplir las siguientes reglas de negocio:

  1. El saldo debe ser un atributo protegido accesible solo mediante una @property.
  2. Método depositar(monto): valida que el monto sea mayor a 0 antes de sumar.
  3. Método retirar(monto): valida que el monto sea positivo y que haya saldo suficiente, lanzando ValueError en caso contrario.
Ver solución del Mini-reto 3
class CuentaBancaria:
    def __init__(self, titular: str, saldo_inicial: float = 0.0):
        if saldo_inicial < 0:
            raise ValueError("El saldo inicial no puede ser negativo.")
        self.titular = titular
        self._saldo = saldo_inicial

    @property
    def saldo(self) -> float:
        return self._saldo

    def depositar(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto a depositar debe ser mayor a cero.")
        self._saldo += monto

    def retirar(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto a retirar debe ser positivo.")
        if monto > self._saldo:
            raise ValueError("Fondos insuficientes para realizar el retiro.")
        self._saldo -= monto

cuenta = CuentaBancaria("Percy Almeyda", 500.0)
cuenta.depositar(250.0)
cuenta.retirar(100.0)
print(f"Titular: {cuenta.titular} | Saldo disponible: S/. {cuenta.saldo:.2f}")
Titular: Percy Almeyda | Saldo disponible: S/. 650.00

Puntos clave del Capítulo 8

  • El argumento self representa la instancia viva y es obligatorio en métodos de instancia.
  • Implementa siempre __repr__ para facilitar el rastreo de objetos durante la depuración.
  • Usa @property para crear getters idiomáticos y agregar validaciones en los setters.
  • @classmethod recibe la clase cls y es ideal para constructores alternativos.
  • Usa @dataclass(slots=True) para crear modelos de datos limpios, rápidos y con mínimo consumo de RAM.

9 · Manejo de archivos, pathlib y JSON

Intermedio ~15 min

Cualquier aplicación real necesita persistir información en disco o intercambiar datos estructurados con APIs. En este capítulo aprenderás a manipular archivos de forma segura con el administrador de contexto with, sustituirás los antiguos métodos de rutas por el módulo moderno pathlib.Path y dominarás la serialización en formato JSON respetando la codificación UTF-8 internacional.

  • Garantizar el cierre de recursos y descriptores del sistema operativo con with open().
  • Dominar los modos de apertura (r, w, a) y el parámetro innegociable encoding="utf-8".
  • Manipular rutas orientadas a objetos con pathlib.Path y el operador división (/).
  • Leer y escribir archivos de texto en una sola línea con read_text() y write_text().
  • Serializar y deserializar datos con el módulo json (dump, load, dumps, loads).
  • Resolver el Mini-reto 4 de persistencia de inventario en JSON.

La sentencia with open: cierre garantizado

En Python nunca debes abrir archivos con f = open(...) sin cerrarlos explícitamente. Si ocurre un error antes del f.close(), el archivo quedará bloqueado por el sistema operativo. La sentencia with implementa el protocolo de *Context Manager*, asegurando el cierre inmediato del archivo al abandonar el bloque:

# Escritura segura con codificación UTF-8
with open("registro.txt", "w", encoding="utf-8") as archivo:
    archivo.write("Línea 1: Registro inicial de auditoría\n")
    archivo.write("Línea 2: Operación completada con éxito en Perú\n")

# Lectura línea a línea (eficiente en memoria para archivos gigantes)
with open("registro.txt", "r", encoding="utf-8") as archivo:
    for num_linea, linea in enumerate(archivo, start=1):
        print(f"[{num_linea}] {linea.strip()}")
[1] Línea 1: Registro inicial de auditoría [2] Línea 2: Operación completada con éxito en Perú
Regla de oro de codificación: Especifica siempre encoding="utf-8". Por defecto, Windows utiliza codificaciones regionales heredadas (como CP-1252), lo que provoca errores al leer tildes, eñes o caracteres especiales en scripts compartidos entre diferentes sistemas operativos.

Rutas modernas con pathlib.Path

El módulo estándar pathlib (PEP 428) reemplazó definitivamente al antiguo y tosco os.path. Las rutas son objetos vivos que se componen mediante el operador división /:

from pathlib import Path

# Definición de ruta base y subcarpetas con el operador /
carpeta_reportes = Path("almacen") / "reportes_2026"

# Crear directorio si no existe (con carpetas intermedias)
carpeta_reportes.mkdir(parents=True, exist_ok=True)

archivo_datos = carpeta_reportes / "resumen.txt"

# Escritura rápida en una sola línea (abre, escribe y cierra automáticamente)
archivo_datos.write_text("Ventas totales del mes: S/. 185,400.00", encoding="utf-8")

# Lectura directa
if archivo_datos.exists():
    contenido = archivo_datos.read_text(encoding="utf-8")
    print(f"Nombre archivo: {archivo_datos.name}")
    print(f"Extensión:      {archivo_datos.suffix}")
    print(f"Contenido:      {contenido}")
Nombre archivo: resumen.txt Extensión: .txt Contenido: Ventas totales del mes: S/. 185,400.00

Intercambio de datos con JSON

El formato JSON (*JavaScript Object Notation*) es el estándar universal de intercambio en servicios web y APIs. El módulo json de la biblioteca estándar traduce limpiamente los tipos nativos:

Función Origen / Destino Propósito
json.dumps(obj) Memoria → String Serializa un diccionario o lista Python a una cadena de texto JSON.
json.loads(cadena) String → Memoria Parsea una cadena de texto JSON y la convierte a objetos Python.
json.dump(obj, file) Memoria → Archivo físico Escribe directamente la estructura en un archivo abierto en disco.
json.load(file) Archivo físico → Memoria Lee directamente un archivo físico de disco y retorna la estructura en Python.
import json
from pathlib import Path

configuracion = {
    "servidor": "api.webcode.net.pe",
    "puerto": 443,
    "ssl_activo": True,
    "rutas_permitidas": ["/usuarios", "/citas", "/reportes"],
    "limite_conexiones": None
}

ruta_json = Path("config.json")

# Guardar con indentación legible y preservación de caracteres Unicode
with open(ruta_json, "w", encoding="utf-8") as f:
    json.dump(configuracion, f, indent=2, ensure_ascii=False)

# Cargar el archivo de vuelta a memoria
with open(ruta_json, "r", encoding="utf-8") as f:
    datos_cargados = json.load(f)

print(f"Servidor destino: {datos_cargados['servidor']}")
print(f"¿SSL habilitado?  {datos_cargados['ssl_activo']}")
print(f"Total de rutas:   {len(datos_cargados['rutas_permitidas'])}")
Servidor destino: api.webcode.net.pe ¿SSL habilitado? True Total de rutas: 3

Mini-reto 4: Gestor de inventario persistente en JSON

Consigna: Desarrolla un pequeño script que gestione el stock de una tienda:

  1. Crea un archivo inventario.json con una lista de artículos (con id, nombre y stock).
  2. Implementa una función actualizar_stock(id_producto, cantidad) que cargue el JSON, busque el producto, modifique su stock y guarde los cambios de vuelta en disco.
  3. Verifica la actualización volviendo a leer el archivo.
Ver solución del Mini-reto 4
import json
from pathlib import Path

ARCHIVO_BD = Path("inventario.json")

# 1. Sembrar inventario inicial
datos_iniciales = [
    {"id": 1, "nombre": "Mouse Inalámbrico", "stock": 15},
    {"id": 2, "nombre": "Teclado Mecánico",   "stock": 8},
]

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

# 2. Función de actualización atómica
def actualizar_stock(id_prod: int, delta_stock: int) -> bool:
    with open(ARCHIVO_BD, "r", encoding="utf-8") as f:
        inventario = json.load(f)

    encontrado = False
    for item in inventario:
        if item["id"] == id_prod:
            item["stock"] += delta_stock
            encontrado = True
            break

    if encontrado:
        with open(ARCHIVO_BD, "w", encoding="utf-8") as f:
            json.dump(inventario, f, indent=2, ensure_ascii=False)
    return encontrado

# Actualizar el stock del producto 2 (+5 unidades)
actualizar_stock(2, 5)

# Comprobar estado final
with open(ARCHIVO_BD, "r", encoding="utf-8") as f:
    estado_final = json.load(f)

for art in estado_final:
    print(f"[{art['id']}] {art['nombre']:18} -> Stock: {art['stock']}")
[1] Mouse Inalámbrico -> Stock: 15 [2] Teclado Mecánico -> Stock: 13

Puntos clave del Capítulo 9

  • La sentencia with open(...) cierra siempre el archivo de forma atómica y segura.
  • Usa encoding="utf-8" para garantizar compatibilidad entre Windows, Mac y Linux.
  • pathlib.Path permite componer rutas orientadas a objetos mediante el operador /.
  • Usa read_text() y write_text() para operaciones rápidas sin boilerplate.
  • En el módulo json, usa indent=2 para legibilidad y ensure_ascii=False para tildes.

10 · Módulos, paquetes y estructura de proyecto

Básico ~15 min

A medida que una aplicación crece, mantener todo el código en un único archivo se vuelve insostenible. Python ofrece un sistema de modularización limpio y jerárquico basado en módulos y paquetes. En este capítulo aprenderás a dividir tu lógica en componentes reutilizables, comprenderás el rol de __init__.py y dominarás el patrón fundamental if __name__ == '__main__':.

  • Entender qué es un módulo (.py) y las diferentes formas de importación.
  • Dominar el patrón idiomático if __name__ == '__main__': para separar ejecución de biblioteca.
  • Organizar carpetas en paquetes Python mediante el archivo __init__.py.
  • Controlar importaciones públicas mediante la lista especial __all__.
  • Adoptar la estructura estándar de proyectos profesionales recomendada por la comunidad.

¿Qué es un módulo y cómo se importa?

En Python, cualquier archivo con extensión .py es automáticamente un módulo. Su nombre de módulo corresponde al nombre del archivo sin la extensión.

# Archivo: calculos.py
def calcular_igv(monto: float, tasa: float = 0.18) -> float:
    """Calcula el impuesto general a las ventas (Perú)."""
    return round(monto * tasa, 2)

def calcular_total(monto: float, tasa: float = 0.18) -> float:
    return round(monto + calcular_igv(monto, tasa), 2)

Para utilizar estas funciones desde otro archivo en el mismo directorio, dispones de tres sintaxis:

# 1. Importar el módulo completo (recomendado por claridad de origen)
import calculos
subtotal = 1000.0
total = calculos.calcular_total(subtotal)
print(f"Total con módulo completo: S/. {total:.2f}")

# 2. Importar funciones específicas al espacio de nombres local
from calculos import calcular_igv
impuesto = calcular_igv(subtotal)
print(f"IGV directo: S/. {impuesto:.2f}")

# 3. Importar con alias para evitar colisiones de nombres o abreviar
import calculos as calc
print(f"Con alias calc: S/. {calc.calcular_total(500.0):.2f}")
Total con módulo completo: S/. 1180.00 IGV directo: S/. 180.00 Con alias calc: S/. 590.00
Antipatrón a evitar: Nunca uses from modulo import *. Las importaciones comodín contaminan tu espacio de nombres local con variables y funciones invisibles, provocando sobreescrituras accidentales y dificultando la lectura del código a otros programadores y a Pylance.

El patrón if __name__ == '__main__':

Cuando Python ejecuta un archivo directamente desde la terminal (python3 mi_script.py), asigna internamente a la variable especial __name__ el valor literal "__main__". En cambio, si el archivo es importado desde otro módulo, __name__ toma el nombre del archivo (por ejemplo, "calculos").

# Archivo: servicio.py

def iniciar_servidor(puerto: int = 8080) -> None:
    print(f"Servicio levantado y escuchando en el puerto {puerto}...")

# Bloque de ejecución directa (aislado de importaciones)
if __name__ == "__main__":
    print("Ejecutando servicio directamente desde la terminal:")
    iniciar_servidor(9000)

Gracias a esta guarda condicional, si otro archivo escribe import servicio, la función iniciar_servidor estará disponible para su uso sin que el servidor se arranque accidentalmente durante la importación.

Paquetes y el rol de __init__.py

Un paquete es simplemente una carpeta que agrupa varios módulos relacionados. La presencia del archivo __init__.py (que puede estar vacío) le indica a Python que ese directorio debe tratarse como un paquete importable.

# Estructura de un paquete de clínica: clinica/ ├── __init__.py # Inicializa el paquete y expone la API pública ├── pacientes.py # Lógica y modelo de pacientes ├── medicos.py # Lógica y modelo de médicos └── citas.py # Gestión y agendamiento de citas

En __init__.py puedes definir qué funciones o clases se exponen públicamente mediante la lista especial __all__:

# clinica/__init__.py
from .pacientes import Paciente
from .citas import agendar_cita

__all__ = ["Paciente", "agendar_cita"]

De este modo, quien consuma tu librería puede importar directamente desde el paquete raíz:

# main.py
from clinica import Paciente, agendar_cita

p = Paciente(1, "Percy Almeyda", "44556677")
print(f"Paciente listo para consulta: {p}")

Estructura estándar de un proyecto profesional

Para mantener tus proyectos ordenados y listos para pruebas automatizadas y despliegue, la comunidad de Python recomienda la siguiente distribución de directorios:

mi_aplicacion/ ├── .venv/ # Entorno virtual aislado (ignorado en .gitignore) ├── .vscode/ # Configuraciones del editor (settings.json, launch.json) ├── mi_paquete/ # Código fuente del proyecto │ ├── __init__.py │ ├── modelo.py │ ├── logica.py │ └── utilidades.py ├── tests/ # Pruebas automatizadas (pytest) │ ├── __init__.py │ └── test_logica.py ├── requirements.txt # Manifiesto estricto de librerías ├── README.md # Documentación de instalación y uso └── main.py # Punto de entrada ejecutable de la app

Puntos clave del Capítulo 10

  • Todo archivo .py es un módulo; una carpeta con __init__.py es un paquete.
  • Usa siempre importaciones explícitas y evita el antipatrón from modulo import *.
  • El bloque if __name__ == '__main__': diferencia scripts ejecutables de módulos de biblioteca.
  • Usa __all__ en __init__.py para definir la API pública limpia de tu paquete.
  • Separa siempre el código de producción de la suite de pruebas en una carpeta tests/ independiente.

11 · Manejo de excepciones y errores comunes

Intermedio ~15 min

En el desarrollo de software real, los errores no son la excepción sino la norma: archivos inexistentes, conexiones caídas o entradas inválidas del usuario. Python aborda los fallos bajo el principio de "Es más fácil pedir perdón que pedir permiso" (EAFP: Easier to ask for forgiveness than permission). En este capítulo dominarás la estructura completa try/except/else/finally, el encadenamiento de excepciones y la creación de clases de error de dominio propias.

  • Comprender la jerarquía nativa: BaseException frente a Exception.
  • Dominar las cuatro partes del bloque de control: try, except, else y finally.
  • Evitar el antipatrón peligroso del except: pass que silencia errores críticos.
  • Lanzar y propagar fallos con raise y encadenar causas forenses con from.
  • Crear excepciones personalizadas heredando de la clase base Exception.

La jerarquía de excepciones en Python

Todas las excepciones en Python derivan de BaseException. Es crítico entender la diferencia para no capturar accidentalmente señales de control del sistema operativo:

Clase raíz Excepciones hijas ¿Debe capturarse en código normal?
BaseException KeyboardInterrupt (Ctrl+C), SystemExit (sys.exit) NUNCA: Si las capturas, impedirás que el usuario o el servidor detengan el proceso.
Exception ValueError, TypeError, KeyError, FileNotFoundError... SÍ: Es la clase base de todos los errores operativos de aplicación.

El bloque completo: try / except / else / finally

Un manejo robusto de excepciones no se limita a un simple try/except:

  • try: Código que contiene la operación riesgosa.
  • except TipoError as e: Se ejecuta solo si ocurrió ese tipo específico de fallo.
  • else: Se ejecuta únicamente si NO ocurrió ninguna excepción en el bloque try. Ideal para continuar la lógica exitosa.
  • finally: Se ejecuta siempre de forma incondicional (haya habido error o no), perfecto para liberar sockets o conexiones.
def dividir_seguro(dividendo: float, divisor: float) -> float | None:
    resultado = None
    try:
        resultado = dividendo / divisor
    except ZeroDivisionError as error:
        print(f"Error operativo controlado: {error}")
    except TypeError as error:
        print(f"Error de tipos incompatibles: {error}")
    else:
        print(f"Cálculo completado con éxito: {dividendo} / {divisor} = {resultado}")
    finally:
        print("Fin de la rutina de división (limpieza completada).")
    return resultado

print("--- Caso 1: División válida ---")
dividir_seguro(20.0, 4.0)

print("\n--- Caso 2: División entre cero ---")
dividir_seguro(10.0, 0.0)
--- Caso 1: División válida --- Cálculo completado con éxito: 20.0 / 4.0 = 5.0 Fin de la rutina de división (limpieza completada). --- Caso 2: División entre cero --- Error operativo controlado: division by zero Fin de la rutina de división (limpieza completada).
El peor antipatrón de Python: Escribir except Exception: pass o except: pass silencia errores catastróficos (como variables mal escritas que arrojan NameError), haciendo que el sistema falle silenciosamente de formas imposibles de rastrear. Captura siempre el tipo de excepción más concreto posible.

Lanzar y encadenar excepciones con raise y from

Usa la sentencia raise para interrumpir la ejecución cuando no se cumplan las precondiciones. Si estás capturando un error de bajo nivel y deseas traducirlo a un error de negocio sin perder la causa original, usa raise ... from ... (PEP 3134):

class ConfiguracionError(Exception):
    """Excepción de dominio para problemas de configuración."""
    pass

def leer_puerto_servidor(config: dict) -> int:
    try:
        valor_raw = config["puerto"]
        return int(valor_raw)
    except KeyError as causa_original:
        # Encadena la excepción original como causa directa (__cause__)
        raise ConfiguracionError("Falta la clave obligatoria 'puerto' en la configuración.") from causa_original
    except ValueError as causa_original:
        raise ConfiguracionError(f"El valor '{valor_raw}' no es un número de puerto válido.") from causa_original

# Ejemplo de uso con captura:
config_invalida = {"puerto": "invalido_8080"}

try:
    puerto = leer_puerto_servidor(config_invalida)
except ConfiguracionError as e:
    print(f"Error detectado: {e}")
    print(f"Causa subyacente: {e.__cause__}")
Error detectado: El valor 'invalido_8080' no es un número de puerto válido. Causa subyacente: invalid literal for int() with base 10: 'invalido_8080'

Creación de excepciones de dominio personalizadas

En aplicaciones profesionales, crear tus propias excepciones hace que el código sea autodocumentado y fácil de atrapar en pruebas:

class ClinicaError(Exception):
    """Clase base para todos los errores del sistema de la clínica."""
    pass

class MedicoNoDisponibleError(ClinicaError):
    """Se lanza cuando un médico ya tiene una cita agendada en ese horario."""
    def __init__(self, medico: str, fecha_hora: str):
        self.medico = medico
        self.fecha_hora = fecha_hora
        super().__init__(f"El Dr(a). {medico} no está disponible el {fecha_hora}.")

# Simulación de validación de agenda
def agendar(medico: str, fecha: str, ocupados: set) -> None:
    if (medico, fecha) in ocupados:
        raise MedicoNoDisponibleError(medico, fecha)
    print(f"Cita confirmada con {medico} para el {fecha}.")

horarios_ocupados = {("Dr. Carlos Mendoza", "2026-09-10 10:00")}

try:
    agendar("Dr. Carlos Mendoza", "2026-09-10 10:00", horarios_ocupados)
except MedicoNoDisponibleError as error:
    print(f"Alerta de recepción: {error}")
    print(f"Médico afectado: {error.medico}")
Alerta de recepción: El Dr(a). Carlos Mendoza no está disponible el 2026-09-10 10:00. Médico afectado: Dr. Carlos Mendoza

Puntos clave del Capítulo 11

  • Captura siempre clases derivadas de Exception, nunca de BaseException.
  • El bloque else solo corre si no hubo fallos en el try; finally corre siempre.
  • Nunca uses except: pass vacío; esconde errores graves e impide la depuración.
  • Usa raise CustomError from original para mantener la causa forense en el traceback.
  • Crea excepciones de negocio heredando de una clase base común de tu proyecto (class MiError(Exception): pass).

12 · Características avanzadas y AsyncIO

Avanzado ~20 min

Python combina una sintaxis accesible con primitivas de arquitectura de alto nivel. En este capítulo dominarás los decoradores para modificar funciones limpiamente, los generadores con yield para procesar flujos de datos con memoria constante, el sistema de Type Hints moderno y los fundamentos de la programación asíncrona con asyncio.

  • Crear decoradores profesionales preservando metadatos con functools.wraps.
  • Implementar generadores con yield para ahorrar megabytes de memoria RAM en grandes colecciones.
  • Aplicar anotaciones de tipo modernas con el operador unión (|) y colecciones genéricas nativas.
  • Comprender el Event Loop de AsyncIO y la concurrencia no bloqueante.
  • Coordinar múltiples corrutinas en paralelo mediante async def, await y asyncio.gather().

Decoradores: extender funciones limpiamente

Un decorador es una función que recibe como argumento a otra función, añade funcionalidad antes y después de su ejecución, y retorna la función envuelta. Es el patrón estándar para autenticación, registro de métricas y auditoría:

import time
from functools import wraps

def medir_tiempo(funcion):
    """Decorador que mide el tiempo de ejecución de cualquier función."""
    @wraps(funcion) # Preserva el nombre y docstring original de la función
    def wrapper(*args, **kwargs):
        inicio = time.perf_counter()
        resultado = funcion(*args, **kwargs)
        fin = time.perf_counter()
        milisegundos = (fin - inicio) * 1000
        print(f"[METRICA] {funcion.__name__} tardó {milisegundos:.3f} ms")
        return resultado
    return wrapper

@medir_tiempo
def simular_calculo_pesado(n: int) -> int:
    """Calcula la suma de cuadrados hasta n."""
    return sum(x**2 for x in range(n))

total = simular_calculo_pesado(100_000)
print(f"Resultado: {total}")
print(f"Nombre de la función preservado: {simular_calculo_pesado.__name__}")
[METRICA] simular_calculo_pesado tardó 8.245 ms Resultado: 333328333350000 Nombre de la función preservado: simular_calculo_pesado

Generadores y yield: memoria constante O(1)

Una función regular con return calcula toda la lista de golpe y la almacena en la memoria RAM. Si manejas millones de registros o lees archivos gigantescos, esto puede agotar la memoria de tu servidor. Un generador utiliza la sentencia yield: entrega un valor, congela su estado y cede el control, reanudando la ejecución solo cuando el consumidor solicita el siguiente elemento:

import sys

# 1. Enfoque con lista (almacena todo en memoria):
def lista_cuadrados(n: int) -> list[int]:
    return [i**2 for i in range(n)]

# 2. Enfoque con generador (produce en demanda):
def generador_cuadrados(n: int):
    for i in range(n):
        yield i**2

lista = lista_cuadrados(10_000)
generador = generador_cuadrados(10_000)

print(f"RAM Lista de 10,000 ints:      {sys.getsizeof(lista)} bytes")
print(f"RAM Generador (constante O(1)): {sys.getsizeof(generador)} bytes")

# Consumo bajo demanda:
print(f"Primer valor:  {next(generador)}")
print(f"Segundo valor: {next(generador)}")
RAM Lista de 10,000 ints: 85176 bytes RAM Generador (constante O(1)): 200 bytes Primer valor: 0 Segundo valor: 1

Type Hints modernos (Python 3.10+ y 3.13)

Las anotaciones de tipo mejoran drásticamente la asistencia de Pylance en VS Code y permiten detectar errores antes de ejecutar. En versiones modernas se utiliza el operador tubería (|) para uniones de tipos:

from typing import TypedDict

# Tipado de colecciones nativas directas (sin importar de typing)
def procesar_calificaciones(notas: list[float]) -> dict[str, float]:
    return {
        "promedio": sum(notas) / len(notas),
        "maxima": max(notas),
        "minima": min(notas),
    }

# Tipado de diccionarios estructurados con TypedDict
class UsuarioDTO(TypedDict):
    id: int
    email: str
    activo: bool
    telefono: str | None # Tipo opcional con operador unión |

perfil: UsuarioDTO = {
    "id": 1,
    "email": "percy@webcode.net.pe",
    "activo": True,
    "telefono": None
}

print(f"Perfil tipado: {perfil['email']} (Activo: {perfil['activo']})")
Perfil tipado: percy@webcode.net.pe (Activo: True)

Programación asíncrona con AsyncIO

En operaciones de entrada/salida (I/O bound) como consultar APIs externas, leer bases de datos o descargar archivos, un hilo sincrónico pasa la mayor parte del tiempo inactivo esperando respuestas de red. AsyncIO permite que un solo hilo ejecute cientos de tareas de forma concurrente: mientras una petición espera respuesta, el procesador atiende otras tareas.

Conceptos clave:

  • async def: Declara una corrutina (una función que puede pausarse y reanudarse).
  • await: Pausa la ejecución de la corrutina actual y cede el control al Event Loop mientras se completa la operación asíncrona.
  • asyncio.gather(): Ejecuta múltiples corrutinas en paralelo de forma concurrente.
  • asyncio.run(): Crea el bucle de eventos, ejecuta la corrutina principal y cierra los recursos limpiamente.
import asyncio
import time

async def consultar_servicio(servicio: str, retardo: float) -> dict:
    """Simula una llamada de red asíncrona a un microservicio."""
    print(f"[INICIO] Solicitando datos a {servicio}...")
    await asyncio.sleep(retardo) # Operación I/O no bloqueante
    print(f"[FIN] Respuesta recibida de {servicio} ({retardo}s).")
    return {"servicio": servicio, "estado": "OK"}

async def main():
    inicio = time.perf_counter()

    # Ejecución concurrente en paralelo de 3 consultas
    resultados = await asyncio.gather(
        consultar_servicio("Servicio-Citas", 1.0),
        consultar_servicio("Servicio-Pacientes", 1.2),
        consultar_servicio("Servicio-Facturacion", 0.8),
    )

    total_tiempo = time.perf_counter() - inicio
    print(f"\nResultados consolidados: {len(resultados)} respuestas.")
    print(f"Tiempo total con AsyncIO: {total_tiempo:.2f} segundos")
    print("(En modo sincrónico habría tardado 1.0 + 1.2 + 0.8 = 3.0 segundos)")

# Punto de entrada de la aplicación asíncrona
asyncio.run(main())
[INICIO] Solicitando datos a Servicio-Citas... [INICIO] Solicitando datos a Servicio-Pacientes... [INICIO] Solicitando datos a Servicio-Facturacion... [FIN] Respuesta recibida de Servicio-Facturacion (0.8s). [FIN] Respuesta recibida de Servicio-Citas (1.0s). [FIN] Respuesta recibida de Servicio-Pacientes (1.2s). Resultados consolidados: 3 respuestas. Tiempo total con AsyncIO: 1.20 segundos (En modo sincrónico habría tardado 1.0 + 1.2 + 0.8 = 3.0 segundos)

Puntos clave del Capítulo 12

  • Los decoradores extienden funciones; usa siempre @wraps para conservar __name__ y metadatos.
  • Los generadores con yield ofrecen procesamiento de flujos infinitos con consumo de memoria $O(1)$.
  • Usa la sintaxis de unión tipo | None y colecciones nativas list[str] para un tipado limpio.
  • AsyncIO es ideal para tareas I/O intensivas (red, APIs, bases de datos) reduciendo la latencia de forma radical.
  • Usa asyncio.gather() para ejecutar múltiples llamadas remotas de manera concurrente en un solo hilo.

13 · Testing profesional con unittest y pytest

Intermedio ~25 min

Escribir software profesional exige garantizar que cada función responde exactamente como se espera ante entradas válidas, casos límite y errores inesperados. En este capítulo dominarás la suite estándar unittest, el estándar moderno de la industria pytest, fixtures avanzadas para inyección de dependencias, pruebas parametrizadas y la depuración visual de pruebas en Visual Studio Code.

  • Comprender la pirámide de pruebas y el valor de las pruebas unitarias automatizadas.
  • Escribir suites clásicas con unittest.TestCase, setUp() y aserciones nativas.
  • Dominar pytest: aserciones directas con assert y reporte automático de fallos.
  • Inyectar recursos limpios con fixtures (@pytest.fixture) y la fixture nativa tmp_path.
  • Evaluar múltiples escenarios sin duplicar código usando @pytest.mark.parametrize.
  • Capturar y validar excepciones de dominio esperadas con pytest.raises.
  • Integrar y depurar tests con interfaz gráfica en Visual Studio Code.

1. ¿Por qué testear? La pirámide de pruebas

El testing automatizado no es un trámite al final de un proyecto: es la red de seguridad indispensable para refactorizar y añadir nuevas funcionalidades sin romper el comportamiento existente (evitando regresiones). La pirámide clásica divide las pruebas en tres niveles:

  • Pruebas unitarias (Unit Tests): Verifican funciones o clases de manera aislada. Son instantáneas (milisegundos) y constituyen la base de la pirámide (70%-80% de tus pruebas).
  • Pruebas de integración (Integration Tests): Comprueban que varios módulos colaboran adecuadamente entre sí (por ejemplo, el gestor de tareas interactuando con el sistema de archivos JSON).
  • Pruebas de extremo a extremo (E2E): Simulan la interacción completa del usuario a través de la interfaz o CLI.

2. Testing clásico con la biblioteca estándar: unittest

Python incorpora de serie el módulo unittest. Sigue el modelo de clases derivado de JUnit en Java, donde cada grupo de pruebas hereda de unittest.TestCase y los métodos deben comenzar por test_:

test_calculos_unittest.py
import unittest

def sumar(a: int | float, b: int | float) -> int | float:
    return a + b

def dividir(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("División por cero no permitida.")
    return a / b

class TestOperaciones(unittest.TestCase):
    def setUp(self) -> None:
        """Se ejecuta ANTES de cada test (inicialización)."""
        self.factor = 10

    def tearDown(self) -> None:
        """Se ejecuta DESPUÉS de cada test (limpieza)."""
        pass

    def test_sumar_positivos(self) -> None:
        resultado = sumar(5, self.factor)
        self.assertEqual(resultado, 15)

    def test_dividir_exitoso(self) -> None:
        self.assertAlmostEqual(dividir(10, 3), 3.3333333, places=6)

    def test_dividir_por_cero_lanza_error(self) -> None:
        with self.assertRaises(ValueError) as ctx:
            dividir(10, 0)
        self.assertIn("División por cero", str(ctx.exception))

if __name__ == "__main__":
    unittest.main()

Para ejecutar el descubrimiento automático desde la terminal sin modificar tus archivos:

Terminal (CLI)
python -m unittest discover -v

3. El estándar de la industria: pytest

Aunque unittest viene integrado, la inmensa mayoría de los proyectos profesionales en Python utilizan pytest. Elimina el código repetitivo, sustituye las decenas de métodos self.assert* por la palabra reservada nativa assert y ofrece reportes con inspección inteligente del AST:

Instalación de pytest
# Activa tu entorno virtual antes de instalar
pip install pytest

4. Aserciones directas y verificación de excepciones

En pytest las pruebas son simples funciones que comienzan con test_. Si un assert falla, pytest imprime el valor intermedio de cada variable evaluada:

test_funciones_pytest.py
import pytest

def normalizar_usuario(nombre: str) -> str:
    limpio = nombre.strip().lower()
    if not limpio:
        raise ValueError("El nombre de usuario no puede estar vacío.")
    return limpio

def test_normalizar_usuario_exitoso() -> None:
    assert normalizar_usuario("  Percy Almeyda  ") == "percy almeyda"
    assert "percy" in normalizar_usuario("Percy")

def test_normalizar_usuario_vacio_lanza_excepcion() -> None:
    with pytest.raises(ValueError) as exc_info:
        normalizar_usuario("   ")
    assert "no puede estar vacío" in str(exc_info.value)

5. Fixtures: Inyección de dependencias y tmp_path

Las fixtures (@pytest.fixture) preparan recursos reutilizables. Además, pytest incluye la fixture nativa tmp_path, que crea un directorio temporal aislado del sistema de archivos y lo limpia automáticamente al culminar la ejecución:

test_fixtures.py
from pathlib import Path
import pytest

@pytest.fixture
def datos_paciente() -> dict[str, str | int]:
    return {"id": 101, "nombre": "Carlos Mendoza", "edad": 42, "tipo_sangre": "O+"}

def test_paciente_es_mayor_de_edad(datos_paciente: dict[str, str | int]) -> None:
    # La fixture se inyecta por el nombre del argumento
    assert datos_paciente["edad"] >= 18
    assert datos_paciente["tipo_sangre"] in {"A+", "O+", "B+", "AB+", "O-"}

def test_guardar_reporte_aislado(tmp_path: Path) -> None:
    # tmp_path es un directorio temporal seguro y aislado en disco
    archivo = tmp_path / "reporte.txt"
    archivo.write_text("Prueba superada", encoding="utf-8")
    assert archivo.exists()
    assert archivo.read_text(encoding="utf-8") == "Prueba superada"

6. Pruebas parametrizadas: @pytest.mark.parametrize

Permite ejecutar la misma función de prueba contra una matriz de combinaciones de entradas y salidas esperadas:

test_parametrizado.py
import pytest

def es_palindromo(texto: str) -> bool:
    limpio = "".join(c.lower() for c in texto if c.isalnum())
    return limpio == limpio[::-1]

@pytest.mark.parametrize(
    "entrada, esperado",
    [
        ("radar", True),
        ("reconocer", True),
        ("A Santa at NASA", True),
        ("Python 3.13", False),
        ("", True),
    ]
)
def test_es_palindromo(entrada: str, esperado: bool) -> None:
    assert es_palindromo(entrada) == esperado

7. Integración visual en Visual Studio Code

En el panel lateral izquierdo de VS Code encontrarás el icono de pruebas (matraz de laboratorio). Al hacer clic en Configure Python Tests y elegir pytest, el editor descubrirá todos tus tests y colocará botones verdes de reproducción directa en el margen de cada función. Puedes ejecutar o depurar paso a paso (Debug Test) cualquier aserción individualmente.

Mini-reto 5: Suite completa de pruebas con pytest

Dada la función de liquidación de descuentos comerciales:

def calcular_descuento(monto: float, porcentaje: float) -> float:
    if monto < 0:
        raise ValueError("El monto no puede ser negativo.")
    if not (0 <= porcentaje <= 100):
        raise ValueError("El porcentaje debe estar comprendido entre 0 y 100.")
    return round(monto * (1 - porcentaje / 100), 2)

Consigna: Escribe un archivo de test con pytest que:
1. Utilice @pytest.mark.parametrize para verificar al menos 4 casos válidos (descuento del 0%, 15%, montos con decimales y 100% de descuento).
2. Verifique con pytest.raises(ValueError) que se rechazan montos negativos y porcentajes inválidos (< 0 o > 100).

Ver solución recomendada
test_descuentos.py (Solución)
import pytest

def calcular_descuento(monto: float, porcentaje: float) -> float:
    if monto < 0:
        raise ValueError("El monto no puede ser negativo.")
    if not (0 <= porcentaje <= 100):
        raise ValueError("El porcentaje debe estar comprendido entre 0 y 100.")
    return round(monto * (1 - porcentaje / 100), 2)

@pytest.mark.parametrize(
    "monto, porcentaje, esperado",
    [
        (100.0, 0.0, 100.0),
        (100.0, 15.0, 85.0),
        (250.50, 10.0, 225.45),
        (500.0, 100.0, 0.0),
    ]
)
def test_calcular_descuento_casos_validos(monto: float, porcentaje: float, esperado: float) -> None:
    assert calcular_descuento(monto, porcentaje) == esperado

@pytest.mark.parametrize(
    "monto, porcentaje, mensaje_esperado",
    [
        (-10.0, 10.0, "El monto no puede ser negativo"),
        (100.0, -5.0, "El porcentaje debe estar comprendido entre 0 y 100"),
        (100.0, 105.0, "El porcentaje debe estar comprendido entre 0 y 100"),
    ]
)
def test_calcular_descuento_errores(monto: float, porcentaje: float, mensaje_esperado: str) -> None:
    with pytest.raises(ValueError) as exc_info:
        calcular_descuento(monto, porcentaje)
    assert mensaje_esperado in str(exc_info.value)

Puntos clave del Capítulo 13

  • unittest vs pytest: unittest viene integrado pero es rígido; pytest es el estándar del mercado por su elegancia basada en assert y funciones directas.
  • Fixtures: Permiten preparar y limpiar dependencias inyectables. La fixture nativa tmp_path es la mejor práctica para pruebas con archivos.
  • Parametrización: Con @pytest.mark.parametrize se verifica una función contra múltiples juegos de datos sin duplicar código.
  • Validación de fallos: pytest.raises(MiError) asegura que el código lance las excepciones de dominio correctas.

14 · Proyecto final modular con suite de pruebas

Proyecto Final ~35 min

Es momento de integrar todo el conocimiento adquirido en un software real, desacoplado y profesional. Construiremos un Gestor de Tareas CLI modularizado en 4 archivos independientes, con validaciones estrictas, persistencia en JSON, excepciones de dominio y una suite completa de pruebas automatizadas con pytest.

  • Diseñar una arquitectura desacoplada basada en el principio de responsabilidad única (SRP).
  • Modelar entidades con alto rendimiento y tipado estricto mediante @dataclass(slots=True).
  • Implementar la lógica del negocio con inyección de dependencias sobre pathlib.Path.
  • Serializar y deserializar información estructurada en JSON con codificación UTF-8.
  • Crear una interfaz CLI intuitiva y robusta gobernada por match-case.
  • Garantizar la calidad del software con una suite de pruebas automatizadas en pytest.
  • Completar la graduación del curso y proyectar tu ruta hacia conceptos avanzados de Python.

1. Arquitectura del proyecto desacoplado

Separamos la responsabilidad de la aplicación en cuatro archivos limpios dentro de una carpeta de paquete:

Estructura modular del proyecto
gestor_tareas/
├── tarea.py         # 1. Modelo de datos estructurado con dataclass
├── gestor.py        # 2. Lógica de negocio, persistencia JSON y excepciones
├── main.py          # 3. Interfaz interactiva CLI con match-case
└── test_gestor.py   # 4. Suite automatizada con pytest y fixtures

2. Módulo 1: Modelo de datos (tarea.py)

Utilizamos @dataclass(slots=True) para máxima velocidad y bajo consumo de memoria, además de conversores a diccionario para serialización JSON:

gestor_tareas/tarea.py
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Any

@dataclass(slots=True)
class Tarea:
    id: int
    titulo: str
    descripcion: str = ""
    completada: bool = False
    creada_en: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M"))

    def to_dict(self) -> dict[str, Any]:
        """Convierte la entidad en un diccionario listo para JSON."""
        return asdict(self)

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "Tarea":
        """Reconstruye una instancia de Tarea a partir de un diccionario."""
        return cls(
            id=data["id"],
            titulo=data["titulo"],
            descripcion=data.get("descripcion", ""),
            completada=data.get("completada", False),
            creada_en=data.get("creada_en", "")
        )

3. Módulo 2: Lógica y persistencia (gestor.py)

El gestor encapsula las reglas del negocio. Aplica inyección de dependencias al recibir la ruta del archivo (pathlib.Path) en su constructor, facilitando las pruebas unitarias sin alterar datos reales:

gestor_tareas/gestor.py
import json
from pathlib import Path
from tarea import Tarea

class GestorError(Exception):
    """Excepción base del dominio gestor de tareas."""

class ValidacionError(GestorError):
    """Lanzada cuando un dato de entrada no cumple las reglas de negocio."""

class TareaNoEncontradaError(GestorError):
    """Lanzada cuando se solicita un identificador inexistente."""

class GestorTareas:
    def __init__(self, ruta_archivo: Path | str = "tareas.json") -> None:
        self.ruta: Path = Path(ruta_archivo)
        self.tareas: list[Tarea] = []
        self._cargar()

    def _cargar(self) -> None:
        if not self.ruta.exists():
            self.tareas = []
            return
        try:
            with self.ruta.open("r", encoding="utf-8") as f:
                datos = json.load(f)
                self.tareas = [Tarea.from_dict(d) for d in datos]
        except (json.JSONDecodeError, OSError):
            self.tareas = []

    def _guardar(self) -> None:
        self.ruta.parent.mkdir(parents=True, exist_ok=True)
        with self.ruta.open("w", encoding="utf-8") as f:
            json.dump([t.to_dict() for t in self.tareas], f, ensure_ascii=False, indent=2)

    def agregar(self, titulo: str, descripcion: str = "") -> Tarea:
        titulo_limpio = titulo.strip()
        if not titulo_limpio:
            raise ValidacionError("El título de la tarea no puede estar vacío.")
        
        nuevo_id = max((t.id for t in self.tareas), default=0) + 1
        nueva_tarea = Tarea(id=nuevo_id, titulo=titulo_limpio, descripcion=descripcion.strip())
        self.tareas.append(nueva_tarea)
        self._guardar()
        return nueva_tarea

    def listar(self, solo_pendientes: bool = False) -> list[Tarea]:
        if solo_pendientes:
            return [t for t in self.tareas if not t.completada]
        return list(self.tareas)

    def completar(self, tarea_id: int) -> Tarea:
        for t in self.tareas:
            if t.id == tarea_id:
                t.completada = True
                self._guardar()
                return t
        raise TareaNoEncontradaError(f"No existe ninguna tarea con ID {tarea_id}.")

    def eliminar(self, tarea_id: int) -> None:
        longitud_previa = len(self.tareas)
        self.tareas = [t for t in self.tareas if t.id != tarea_id]
        if len(self.tareas) == longitud_previa:
            raise TareaNoEncontradaError(f"No existe ninguna tarea con ID {tarea_id}.")
        self._guardar()

4. Módulo 3: Interfaz interactiva CLI (main.py)

Construimos el punto de entrada de la aplicación CLI con match-case y la guarda canónica if __name__ == "__main__"::

gestor_tareas/main.py
from pathlib import Path
from gestor import GestorTareas, ValidacionError, TareaNoEncontradaError

def mostrar_menu() -> None:
    print("\n" + "=" * 40)
    print("      GESTOR DE TAREAS PROFESIONAL")
    print("=" * 40)
    print("1. Listar todas las tareas")
    print("2. Listar solo tareas pendientes")
    print("3. Agregar nueva tarea")
    print("4. Marcar tarea como completada")
    print("5. Eliminar tarea")
    print("6. Salir")
    print("-" * 40)

def main() -> None:
    archivo_db = Path("datos") / "tareas.json"
    gestor = GestorTareas(archivo_db)

    while True:
        mostrar_menu()
        opcion = input("Seleccione una opción (1-6): ").strip()

        match opcion:
            case "1" | "2":
                solo_pend = (opcion == "2")
                tareas = gestor.listar(solo_pendientes=solo_pend)
                if not tareas:
                    print("\n[i] No hay tareas registradas para mostrar.")
                else:
                    print("\n--- LISTADO DE TAREAS ---")
                    for t in tareas:
                        estado = "[✓] Hecha" if t.completada else "[ ] Pendiente"
                        print(f"#{t.id} {estado} | {t.titulo} ({t.creada_en})")
                        if t.descripcion:
                            print(f"    Nota: {t.descripcion}")

            case "3":
                titulo = input("Título de la tarea: ")
                desc = input("Descripción (opcional): ")
                try:
                    nueva = gestor.agregar(titulo, desc)
                    print(f"\n[OK] Tarea #{nueva.id} agregada exitosamente.")
                except ValidacionError as err:
                    print(f"\n[Error de validación]: {err}")

            case "4":
                id_str = input("ID de la tarea a completar: ").strip()
                if not id_str.isdigit():
                    print("\n[!] Debe ingresar un número entero válido.")
                    continue
                try:
                    t = gestor.completar(int(id_str))
                    print(f"\n[OK] Tarea #{t.id} '{t.titulo}' completada.")
                except TareaNoEncontradaError as err:
                    print(f"\n[Error]: {err}")

            case "5":
                id_str = input("ID de la tarea a eliminar: ").strip()
                if not id_str.isdigit():
                    print("\n[!] Debe ingresar un número entero válido.")
                    continue
                try:
                    gestor.eliminar(int(id_str))
                    print(f"\n[OK] Tarea #{id_str} eliminada correctamente.")
                except TareaNoEncontradaError as err:
                    print(f"\n[Error]: {err}")

            case "6":
                print("\n¡Hasta pronto!")
                break

            case _:
                print("\n[!] Opción no válida. Ingrese un valor entre 1 y 6.")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\n\nPrograma interrumpido por el usuario. Saliendo...")

5. Módulo 4: Pruebas automatizadas (test_gestor.py)

Verificamos el funcionamiento de punta a punta empleando la fixture tmp_path para garantizar que cada test opere sobre un archivo JSON aislado y limpio:

gestor_tareas/test_gestor.py
from pathlib import Path
import pytest
from gestor import GestorTareas, ValidacionError, TareaNoEncontradaError

@pytest.fixture
def gestor_aislado(tmp_path: Path) -> GestorTareas:
    """Fixture que provee un gestor con almacenamiento temporal exclusivo."""
    ruta_temporal = tmp_path / "tareas_test.json"
    return GestorTareas(ruta_temporal)

def test_agregar_y_listar_tareas(gestor_aislado: GestorTareas) -> None:
    tarea = gestor_aislado.agregar("Comprar café", "Café arábica de grano")
    assert tarea.id == 1
    assert tarea.titulo == "Comprar café"
    assert tarea.completada is False

    todas = gestor_aislado.listar()
    assert len(todas) == 1
    assert todas[0].id == 1

def test_completar_tarea(gestor_aislado: GestorTareas) -> None:
    t = gestor_aislado.agregar("Revisar PR", "Revisión de pull request")
    assert t.completada is False

    t_actualizada = gestor_aislado.completar(t.id)
    assert t_actualizada.completada is True

    pendientes = gestor_aislado.listar(solo_pendientes=True)
    assert len(pendientes) == 0

def test_eliminar_tarea(gestor_aislado: GestorTareas) -> None:
    t = gestor_aislado.agregar("Tarea temporal")
    assert len(gestor_aislado.listar()) == 1

    gestor_aislado.eliminar(t.id)
    assert len(gestor_aislado.listar()) == 0

def test_agregar_tarea_vacia_lanza_validacion_error(gestor_aislado: GestorTareas) -> None:
    with pytest.raises(ValidacionError) as exc_info:
        gestor_aislado.agregar("   ")
    assert "no puede estar vacío" in str(exc_info.value)

def test_completar_inexistente_lanza_error(gestor_aislado: GestorTareas) -> None:
    with pytest.raises(TareaNoEncontradaError):
        gestor_aislado.completar(999)

def test_persistencia_real(tmp_path: Path) -> None:
    ruta = tmp_path / "persistencia.json"
    g1 = GestorTareas(ruta)
    g1.agregar("Persistir en disco", "Comprobación de I/O")

    g2 = GestorTareas(ruta)
    assert len(g2.listar()) == 1
    assert g2.listar()[0].titulo == "Persistir en disco"

Para ejecutar las pruebas desde la terminal:

Ejecutar pruebas con pytest
pytest gestor_tareas/test_gestor.py -v

¡Felicitaciones! Has completado Python Esencial

De la sintaxis básica a la arquitectura modular y el testing profesional.

A lo largo de estos 14 capítulos has construido cimientos sólidos de ingeniería de software en Python 3.13:

Instalación multiplataforma y nuevo REPL interactivo.
Entornos virtuales (venv) y resolución con Conda / Mamba.
Depuración paso a paso en Visual Studio Code.
Control de flujo moderno con match-case y for...else.
Colecciones nativas, memoria y mutabilidad (copias profundas).
Funciones, clausuras, *args/**kwargs y centinelas None.
POO moderna y @dataclass(slots=True) de alto rendimiento.
Manejo moderno del sistema de archivos con pathlib y JSON.
Paquetes modulares, __all__ y namespaces limpios.
Excepciones de dominio y encadenamiento forense (raise from).
Decoradores con functools.wraps, generadores y AsyncIO.
Pruebas unitarias e integración con pytest y fixtures.

¿Listo para dar el siguiente salto profesional?

Profundiza en el motor CPython, Metaclases, Descriptores, MRO y Concurrencia masiva en nuestra guía de 40 capítulos.

Ir a Python Completo (40 caps)