Módulos y paquetes en Python — práctica con un boletín, un módulo que se prueba solo y un paquete (y el assert que tumbaba mi programa al importar)

Escribí unas pruebas con assert para mi módulo de validación y las dejé al final del archivo, sin más. Funcionaron de maravilla… hasta el día que cambié una constante del módulo y una de esas pruebas dejó de cumplirse. Entonces no fue el módulo el que protestó: fue toda mi aplicación, que se cayó con un AssertionError nada más arrancar, antes de llegar a ninguna de sus propias líneas. Mis pruebas se ejecutaban cada vez que alguien importaba el módulo.

Lo que aprendí de golpe fue que escribir un módulo es una cosa, pero decidir qué debe pasar al importarlo y qué al lanzarlo es otra. Hoy lo practicamos con tres programas completos: un módulo propio con el programa que lo importa, un módulo que se prueba a sí mismo y un paquete con subpaquete.

Esta práctica usa lo de la teoría de módulos y paquetes: import, from … import, __name__ y paquetes. Si vienes de las funciones en Python, aquí las vas a reutilizar desde varios archivos.

Cómo usar este artículo

Cada programa se compone de varios archivos que van en la misma carpeta (y, en el último, en una estructura de carpetas). Créalos tú mismo, copia el código y lánzalo desde la terminal con python3 nombre.py. Si usas un editor con botón de ejecución, comprueba que ejecuta desde la carpeta del proyecto; si no, los import no encontrarán tus módulos.

Ningún programa usa input(). Los datos van fijos en el código para que tu salida sea idéntica a la que ves aquí.

Programa 1 · Un módulo propio y el programa que lo importa

Un boletín de notas necesita media y calificación escrita. Lo separamos en dos archivos: boletin.py, que es la librería, e informe.py, que es el programa que la usa.

boletin.py

"""Utilidades para el boletín de notas."""

NOTA_MAX = 10
APROBADO = 5.0
NOTABLE = 7.0
SOBRESALIENTE = 9.0


def media(a, b, c):
    """Media aritmética de tres notas."""
    return (a + b + c) / 3


def califica(nota):
    """Convierte una nota numérica en su calificación escrita."""
    if nota >= SOBRESALIENTE:
        return "Sobresaliente"
    if nota >= NOTABLE:
        return "Notable"
    if nota >= APROBADO:
        return "Aprobado"
    return "Suspenso"

informe.py

import boletin as bol
from boletin import media, califica

print("=== Boletín de Lucía (sobre", bol.NOTA_MAX, ") ===")

fp1 = media(7.0, 8.5, 9.5)
algebra = media(4.0, 5.5, 3.5)
ic1 = media(6.5, 5.0, 7.5)

print("FP1:    ", round(fp1, 2), "->", califica(fp1))
print("Álgebra:", round(algebra, 2), "->", califica(algebra))
print("IC1:    ", round(ic1, 2), "->", califica(ic1))

if bol.APROBADO > algebra:
    print("Aviso: Álgebra está por debajo de", bol.APROBADO)

Se lanza con python3 informe.py:

=== Boletín de Lucía (sobre 10 ) ===
FP1:     8.33 -> Notable
Álgebra: 4.33 -> Suspenso
IC1:     6.33 -> Aprobado
Aviso: Álgebra está por debajo de 5.0

Entre líneas:

  • boletin.py solo define cosas: constantes y funciones. Si lo lanzas solo, no imprime nada, y eso es justo lo que se espera de una librería.
  • En informe.py usamos las dos formas a la vez: from boletin import media, califica para las funciones que usamos mucho, e import boletin as bol para las constantes, con un alias corto.
  • ¿Por qué bol.APROBADO y no solo APROBADO? Porque no la importamos con from. Si la hubiéramos traído con from boletin import APROBADO, también valdría, pero así se lee de dónde sale el valor.
  • Fíjate en el orden de las comprobaciones en califica: va de la nota más alta a la más baja. Cuando entra en un if, ya no sigue mirando los demás, porque return termina la función.
  • ¿Y si el umbral de Notable cambiara? Basta con tocarlo en boletin.py, y cualquier programa que use el módulo se actualiza solo. Esa es la gracia de modularizar.

Pruébalo tú

  • Añade a boletin.py una función aprobado(nota) que devuelva True o False, y úsala en informe.py en vez de comparar con bol.APROBADO.
  • Añade una cuarta asignatura al boletín. ¿Cuántas líneas has tenido que tocar en boletin.py? (Pista: ninguna.)

Programa 2 · Un módulo que se prueba a sí mismo

Hay módulos que son librería y, a la vez, se pueden lanzar para comprobar que funcionan. Es el truco de if __name__ == "__main__":. Aquí tenemos un módulo para validar notas, con sus pruebas dentro, y una aplicación que lo usa.

validacion.py

"""Validación de notas."""

NOTA_MIN = 0
NOTA_MAX = 10

print("validacion.py: __name__ =", __name__)


def es_nota_valida(nota):
    return NOTA_MIN <= nota <= NOTA_MAX


def limita(nota):
    """Recorta la nota al rango válido."""
    return max(NOTA_MIN, min(nota, NOTA_MAX))


if __name__ == "__main__":
    # Pruebas: solo se ejecutan si lanzas este archivo directamente
    print("Pruebas de validacion.py")
    assert es_nota_valida(7.5)
    assert not es_nota_valida(11)
    assert limita(12) == 10
    assert limita(-3) == 0
    assert limita(6.5) == 6.5
    print("Todo correcto")

app.py

from validacion import es_nota_valida, limita

nota = 11.5
print("¿Válida?", es_nota_valida(nota))
print("Nota corregida:", limita(nota))

Primero lanzamos la aplicación, con python3 app.py:

validacion.py: __name__ = validacion
¿Válida? False
Nota corregida: 10

Y ahora el módulo, directamente, con python3 validacion.py:

validacion.py: __name__ = __main__
Pruebas de validacion.py
Todo correcto

Entre líneas:

  • La primera línea que ves en las dos salidas viene del print que hemos dejado a propósito en el nivel superior del módulo. Fíjate en el valor de __name__: validacion cuando lo importa app.py, __main__ cuando lo lanzas tú.
  • Las pruebas no aparecen cuando se lanza app.py: están bajo el if. En el segundo caso se ejecutan, y como todas se cumplen, solo ves «Todo correcto».
  • assert condicion no hace nada si la condición es verdadera, y detiene el programa con un AssertionError si es falsa. Es una forma rapidísima de dejar escritas las pruebas.
  • limita usa min y max para recortar al rango: min(nota, NOTA_MAX) evita pasarse por arriba y max(NOTA_MIN, ...) por abajo. Si no lo ves claro, pruébalo con 12 y con -3.
  • ¿Y si borrases las pruebas del if y las dejaras sueltas? Volverías a mi historia del principio: cualquier programa que importara el módulo las ejecutaría y, si una fallase, se caería antes de empezar.

Pruébalo tú

  • Rompe una prueba a propósito (por ejemplo, assert limita(12) == 11) y lanza python3 validacion.py. Lee el mensaje de AssertionError. Lanza ahora python3 app.py: ¿sigue funcionando?
  • Añade una función redondea(nota) que redondee a un decimal y su prueba. ¿Dónde la escribes: encima o dentro del if?

Programa 3 · Un paquete con subpaquete

Ahora organizamos un expediente académico en un paquete. Tiene un módulo con los créditos del grado, y un subpaquete notas con dos módulos: uno que calcula y otro que da formato de texto.

main.py
expediente/
├── __init__.py
├── creditos.py
└── notas/
    ├── __init__.py
    ├── calculos.py
    └── texto.py

Los dos __init__.py están vacíos. Estos son los demás archivos:

expediente/creditos.py

CREDITOS_GRADO = 240


def porcentaje_superado(creditos):
    """Porcentaje del grado superado (1 decimal)."""
    return round(creditos / CREDITOS_GRADO * 100, 1)

expediente/notas/calculos.py

def media_ponderada(nota1, cred1, nota2, cred2):
    """Media de dos asignaturas ponderada por sus créditos."""
    return (nota1 * cred1 + nota2 * cred2) / (cred1 + cred2)

expediente/notas/texto.py

from expediente.notas.calculos import media_ponderada


def resumen(nota1, cred1, nota2, cred2):
    media = media_ponderada(nota1, cred1, nota2, cred2)
    return f"Media ponderada: {media:.2f}"

main.py

import expediente.creditos
from expediente.notas.calculos import media_ponderada
from expediente.notas import texto

print("Grado superado:", expediente.creditos.porcentaje_superado(60), "%")
print("Media ponderada:", media_ponderada(9.0, 6, 6.0, 6))
print(texto.resumen(9.0, 6, 6.0, 6))

Se lanza con python3 main.py, desde la carpeta que contiene expediente:

Grado superado: 25.0 %
Media ponderada: 7.5
Media ponderada: 7.50

Entre líneas:

  • texto.py usa la ruta completa para llegar a calculos.py: from expediente.notas.calculos import media_ponderada. Las rutas se escriben siempre desde la carpeta de arriba, que es donde está main.py.
  • main.py usa las tres formas posibles: import expediente.creditos (ruta completa al usar), from expediente.notas.calculos import media_ponderada (función suelta) y from expediente.notas import texto (el módulo, sin la ruta completa).
  • Esta última suele pasar desapercibida: from paquete import modulo también es válido y deja usar modulo.función. Es un término medio entre importar la función y escribir la ruta entera.
  • Los números cuadran: la media ponderada de un 9,0 (6 créditos) y un 6,0 (6 créditos) es 7,5, y 60 de 240 créditos son el 25,0 % del grado.
  • f"{media:.2f}" formatea con dos decimales, por eso texto.resumen da 7.50 y media_ponderada da 7.5.

Pruébalo tú

  • Añade expediente/notas/escala.py con una función que convierta una nota de 0–10 a una escala de 0–4 y úsala desde main.py. ¿Qué línea de importación necesitas?
  • Borra un __init__.py y vuelve a lanzar el programa. ¿Falla? Lee la 🟣 nota de la teoría y razona por qué puede que no.

Míralo paso a paso en Python Tutor

Como Python Tutor solo admite un archivo, juntamos en uno el módulo del Programa 1 y el programa que lo usa. Elige Python 3 y pega el código. Verás cómo la pila de llamadas crece y se vacía cuando unas funciones llaman a otras, que es lo que ocurre entre tus módulos aunque estén en archivos distintos.

APROBADO = 5.0


def media(a, b, c):
    return (a + b + c) / 3


def califica(nota):
    if nota >= 9:
        return "Sobresaliente"
    if nota >= APROBADO:
        return "Aprobado"
    return "Suspenso"


def informe(a, b, c):
    m = media(a, b, c)
    return califica(m)


if __name__ == "__main__":
    print(informe(9.0, 9.5, 8.5))
    print(informe(4.0, 5.0, 3.5))

Salida esperada:

Sobresaliente
Suspenso

Qué observar, paso a paso:

  1. Al empezar, solo existen APROBADO y las tres funciones en las variables globales. Ninguna se ha ejecutado todavía: def solo las crea.
  2. Cuando entra en el bloque if __name__ == "__main__":, Python Tutor lo trata como programa principal. Es el mismo bloque que en validacion.py se saltaría si el archivo fuera importado.
  3. La llamada informe(9.0, 9.5, 8.5) abre un marco nuevo. Dentro, media(a, b, c) abre otro encima: ves la pila con dos marcos, y m guarda 9.0 cuando el segundo se cierra.
  4. califica(m) abre otro marco, devuelve "Sobresaliente" y se cierra. Luego se cierra informe. Entre archivos pasa exactamente lo mismo.
  5. En la segunda llamada, fíjate en que m vale 4.166… y la función entra por el último return: la nota es menor que APROBADO.

Para cerrar

Hoy has visto los tres patrones que usarás siempre: una librería que solo define, un módulo autoprobado gracias al __main__, y un paquete con subpaquete donde cada pieza tiene su sitio. Si algo no te sale, repasa la teoría; y cuando te sientas cómodo, pasa a los ejercicios, donde los fallos vienen incluidos.

Siguiente paso: ejerciciosMódulos y paquetes en Python — ejercicios con temperaturas, divisas y una tienda (y el precio con IVA que salía con descuento)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *