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.
1 · Instalación multiplataforma y pyenv
Básico ~15 minPython 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:
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:
- Ingresa al portal oficial: python.org/downloads/windows.
- Descarga el instalador de 64 bits para Python 3.13 (
python-3.13.x-amd64.exe). -
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. - Opcional pero recomendado al finalizar: haz clic en "Disable path length limit" para eliminar la restricción de 260 caracteres en rutas de Windows.
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:
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:
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:
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,helpocleardirectamente sin los paréntesis. - Atajos de teclado indispensables:
F1(ayuda),F2(historial de comandos previos) yF3(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).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-pipypython3-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 minUno 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.txtfrente aenvironment.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:
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.
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:
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.0El 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:
-
pipes 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),pipasume que ya están instaladas y configuradas en tu sistema operativo. Si faltan, la instalación fallará. -
condaes 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.
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.0La 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. |
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 + pipcomo 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.txten pip oenvironment.ymlen 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
.venvo 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.
- Abre la paleta de comandos de VS Code: Ctrl + Shift + P (o Cmd + Shift + P en Mac).
- Escribe y selecciona: Python: Select Interpreter.
-
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). -
Comprobación visual: En la barra de estado inferior derecha de VS Code verás ahora el texto
3.13.0 ('.venv').
(.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"] > 100olen(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 + P → Python: 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 minLa 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:
- Indentación obligatoria: Cada nivel de bloque lógico debe sangrarse exactamente con 4 espacios (evita mezclar tabuladores).
- Dos puntos delimitadores (
:): Toda sentencia que abre un bloque condicional, bucle, función o clase termina con dos puntos. - Sensibilidad a mayúsculas: Los identificadores
usuario,UsuarioyUSUARIOson tres variables totalmente distintas. - Convención de nombres (PEP 8): Las variables y funciones utilizan
snake_case(ej.total_ventas), las constantesSCREAMING_SNAKE_CASE(ej.MAX_INTENTOS) y las clasesPascalCase.
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}")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")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)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)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=}")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
intposeen 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:.2fpara 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,elsey el operador ternario. - Utilizar
forconrange,enumerateyzip(strict=True). - Gestionar ciclos con
while, interrupcionesbreaky saltoscontinue. - Aprender el patrón de búsqueda idiomática con
for...elsesin banderas booleanas. - Aplicar el potente
match-casecon 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}")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()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}")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}")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.")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"]))Puntos clave del Capítulo 5
- El operador ternario
x if cond else ysimplifica asignaciones condicionales a una sola línea. - Usa siempre
enumerate()para iterar obteniendo el índice yzip(strict=True)para sincronizar listas. - La cláusula
for...elseejecuta su bloque cuando el bucle termina sin disparar ningúnbreak. match-casepermite desestructurar secuencias, combinar alternativas con tuberías|y agregar guardas condicionales.
6 · Colecciones nativas y mutabilidad
Intermedio ~20 minCasi 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,dictyset. - Comprender la diferencia entre identidad de punteros y valor en memoria (
isvs==). - 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}")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}")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}")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}")Tabla comparativa de colecciones
| Colección | Literal | Mutable | Ordenada | Duplicados | Búsqueda (in) |
|---|---|---|---|---|---|
list |
[1, 2, 3] |
Sí | Sí | Sí | $O(n)$ Lineal |
tuple |
(1, 2, 3) |
No | Sí | Sí | $O(n)$ Lineal |
dict |
{"k": "v"} |
Sí | Sí (por inserción) | Claves no, valores sí | $O(1)$ Constante |
set |
{1, 2, 3} |
Sí | 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}")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}")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:
- Deduplique y liste las categorías participantes usando un
set. - Calcule el total acumulado de ventas por categoría usando un
dict. - 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}")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
*argsy**kwargs. - Entender la resolución de variables con la jerarquía LEGB (
globalynonlocal). - Aplicar funciones puras con
map,filteryfunctools.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']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']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
)Ámbitos de variables y la regla LEGB
Cuando Python busca el valor de una variable, consulta estrictamente cuatro ámbitos en orden:
- Local (L): Variables definidas dentro de la función actual.
- Enclosing (E): Variables en funciones contenedoras anidadas (closures).
- Global (G): Variables definidas en el nivel superior del módulo/archivo.
- 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()) # 12Programació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}")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)}")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):
- Filtra los pedidos que tengan
aprobado: True. - Extrae únicamente el monto
totalde cada pedido aprobado. - 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}")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{}); usaNoney crea el objeto dentro. *argsempaqueta argumentos posicionales sobrantes en una tupla;**kwargsen un diccionario.- La regla LEGB rige la visibilidad; usa
nonlocalpara modificar variables de closures. mapyfilterprocesan iterables de forma perezosa yreducecolapsa 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
selfes explícito. - Dominar los métodos mágicos indispensables:
__init__,__repr__y__str__. - Aplicar encapsulamiento seguro con
@propertyy 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())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 porprint()ystr().__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')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 claroMé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)}")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 diccionarioMini-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:
- El saldo debe ser un atributo protegido accesible solo mediante una
@property. - Método
depositar(monto): valida que el monto sea mayor a 0 antes de sumar. - Método
retirar(monto): valida que el monto sea positivo y que haya saldo suficiente, lanzandoValueErroren 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}")Puntos clave del Capítulo 8
- El argumento
selfrepresenta 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
@propertypara crear getters idiomáticos y agregar validaciones en los setters. @classmethodrecibe la claseclsy 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 innegociableencoding="utf-8". - Manipular rutas orientadas a objetos con
pathlib.Pathy el operador división (/). - Leer y escribir archivos de texto en una sola línea con
read_text()ywrite_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()}")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}")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'])}")Mini-reto 4: Gestor de inventario persistente en JSON
Consigna: Desarrolla un pequeño script que gestione el stock de una tienda:
- Crea un archivo
inventario.jsoncon una lista de artículos (conid,nombreystock). - 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. - 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']}")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.Pathpermite componer rutas orientadas a objetos mediante el operador/.- Usa
read_text()ywrite_text()para operaciones rápidas sin boilerplate. - En el módulo
json, usaindent=2para legibilidad yensure_ascii=Falsepara 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}")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.
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:
Puntos clave del Capítulo 10
- Todo archivo
.pyes un módulo; una carpeta con__init__.pyes 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__.pypara 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:
BaseExceptionfrente aException. - Dominar las cuatro partes del bloque de control:
try,except,elseyfinally. - Evitar el antipatrón peligroso del
except: passque silencia errores críticos. - Lanzar y propagar fallos con
raisey encadenar causas forenses confrom. - 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 bloquetry. 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)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__}")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}")Puntos clave del Capítulo 11
- Captura siempre clases derivadas de
Exception, nunca deBaseException. - El bloque
elsesolo corre si no hubo fallos en eltry;finallycorre siempre. - Nunca uses
except: passvacío; esconde errores graves e impide la depuración. - Usa
raise CustomError from originalpara 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
yieldpara 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,awaityasyncio.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__}")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)}")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']})")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())Puntos clave del Capítulo 12
- Los decoradores extienden funciones; usa siempre
@wrapspara conservar__name__y metadatos. - Los generadores con
yieldofrecen procesamiento de flujos infinitos con consumo de memoria $O(1)$. - Usa la sintaxis de unión
tipo | Noney colecciones nativaslist[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 conasserty reporte automático de fallos. - Inyectar recursos limpios con fixtures (
@pytest.fixture) y la fixture nativatmp_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_:
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:
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:
# 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:
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:
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:
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.
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
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:
unittestviene integrado pero es rígido;pytestes el estándar del mercado por su elegancia basada enasserty funciones directas. - Fixtures: Permiten preparar y limpiar dependencias inyectables. La fixture nativa
tmp_pathes la mejor práctica para pruebas con archivos. - Parametrización: Con
@pytest.mark.parametrizese 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:
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:
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:
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__"::
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:
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:
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:
venv) y resolución con Conda / Mamba.match-case y for...else.*args/**kwargs y centinelas None.@dataclass(slots=True) de alto rendimiento.pathlib y JSON.__all__ y namespaces limpios.raise from).functools.wraps, generadores y AsyncIO.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.