Saltar a contenido

1.12 · Módulos y paquetes

Objetivos. Al terminar este capítulo podrás: reutilizar código con import; separar un programa en módulos con responsabilidades claras; usar el bloque if __name__ == "__main__"; y organizar varios módulos como un paquete con importaciones relativas.

Evidencia de logro. Convertirás parte del asistente de notas en un paquete pequeño: la lógica quedará en un módulo reutilizable y la interfaz de terminal en otro módulo que solo se ejecutará cuando se lance como programa.

Contexto y motivación

Un fichero Python puede crecer hasta ser difícil de leer y probar. Un módulo es un fichero .py que reúne código relacionado; un paquete es una carpeta que agrupa módulos que forman una misma unidad.

Separar responsabilidades permite responder preguntas concretas: ¿dónde se carga una nota?, ¿qué código depende de la terminal?, ¿qué puedo reutilizar en una API? Un módulo bien delimitado reduce el acoplamiento y hace visible cada responsabilidad.

Vocabulario

Término Significado
Módulo Fichero .py importable
Paquete Carpeta que agrupa módulos relacionados
Importar Hacer disponible código de otro módulo
API pública Nombres que un módulo ofrece para ser usados
__name__ Nombre con el que Python ha cargado un módulo
__main__ Nombre del fichero que se está ejecutando como programa
Importación relativa Importación desde el paquete actual (.modulo)

Prerrequisitos

1.8 · Funciones, 1.10 · Archivos y 1.11 · Excepciones y logging.

1. Un módulo es un fichero reutilizable

Supón que guardas este código en conversiones.py:

"""Conversiones de temperatura."""


def celsius_a_fahrenheit(celsius):
    return celsius * 9 / 5 + 32


ESCALA_POR_DEFECTO = "C"

Otro fichero puede importarlo:

import conversiones

resultado = conversiones.celsius_a_fahrenheit(20)
print(resultado)
print(conversiones.ESCALA_POR_DEFECTO)

Salida esperada:

68.0
C

import conversiones carga el módulo una vez y obliga a escribir el prefijo conversiones.. Ese prefijo hace explícito de dónde viene cada nombre.

También existen estas formas:

from conversiones import celsius_a_fahrenheit
from conversiones import celsius_a_fahrenheit as a_fahrenheit

print(celsius_a_fahrenheit(0))
print(a_fahrenheit(10))

Salida esperada:

32.0
50.0

Usa from modulo import nombre cuando el nombre sea claro y no haya riesgo de colisión. Evita from modulo import *: hace difícil saber de dónde sale cada variable y puede sobrescribir nombres sin avisar.

2. Separar la ejecución de la importación

Cuando Python importa un módulo, ejecuta sus instrucciones de nivel superior. Por eso un módulo reutilizable no debería arrancar una aplicación al importarse.

# saludo.py

def saludar(nombre):
    return f"Hola, {nombre}"


def main():
    print(saludar("Ana"))


if __name__ == "__main__":
    main()

Si ejecutas uv run python saludo.py, __name__ vale "__main__" y se imprime el saludo. Si otro módulo hace import saludo, __name__ vale "saludo" y solo quedan disponibles las funciones: no se imprime nada automáticamente.

Haz la prueba

Crea saludo.py y probar_saludo.py con import saludo y print(saludo.saludar("Luis")). Si al importar aparece el saludo de main(), la condición __name__ está ausente o mal escrita.

3. De un script a un paquete

Una primera separación del asistente de notas puede tener esta forma:

notas_app/
├── main.py
└── notas/
    ├── __init__.py
    └── almacenamiento.py

notas/almacenamiento.py contiene la lógica de datos:

# notas/almacenamiento.py
import json
from pathlib import Path


def cargar(ruta):
    try:
        with Path(ruta).open(encoding="utf-8") as fichero:
            return json.load(fichero)
    except FileNotFoundError:
        return []


def guardar(notas, ruta):
    destino = Path(ruta)
    destino.parent.mkdir(parents=True, exist_ok=True)
    with destino.open("w", encoding="utf-8") as fichero:
        json.dump(notas, fichero, ensure_ascii=False, indent=2)

main.py contiene la orquestación de la aplicación:

# main.py
from notas.almacenamiento import cargar, guardar


def main():
    ruta = "practica/notas_modulos.json"
    notas = cargar(ruta)
    notas.append("Separar responsabilidades")
    guardar(notas, ruta)
    print(f"Notas guardadas: {len(notas)}")


if __name__ == "__main__":
    main()

Al ejecutar desde la carpeta que contiene main.py:

uv run python main.py

Salida esperada:

Notas guardadas: 1

Es la primera vez que lo ejecutas, así que aún no hay nada escrito. A partir de la segunda cargará lo que ya hay en el fichero, y el número irá creciendo.

main.py conoce las funciones cargar y guardar, pero no necesita saber cómo se abre el JSON. En una API futura podremos reutilizar el almacenamiento sin copiar la lógica.

3.1 __init__.py y la API del paquete

__init__.py se ejecuta al importar el paquete y puede exponer una API pequeña:

# notas/__init__.py
from .almacenamiento import cargar, guardar

__all__ = ["cargar", "guardar"]

Ahora también es posible escribir:

from notas import cargar, guardar

print(cargar.__name__, guardar.__name__)

Salida esperada:

cargar guardar

El punto de .almacenamiento significa «desde este mismo paquete». Fíjate en que el bloque anterior y este importan lo mismo de dos maneras distintas: la primera va al submódulo y luego toma un nombre; esta segunda usa lo que __init__.py expone. Para quien usa tu paquete da igual: por eso conviene que __init__.py reúna lo que quieres ofrecer.

__all__ documenta los nombres que consideramos públicos cuando alguien usa una importación con *; no sustituye a una documentación clara ni protege contra accesos directos a otros nombres.

¿Es obligatorio __init__.py?

Python moderno admite paquetes de espacio de nombres sin ese fichero, pero un __init__.py explícito hace visible la intención, permite definir una API y resulta más sencillo para un curso y para herramientas de desarrollo. En los paquetes del curso lo usaremos de forma explícita.

4. Importaciones absolutas y relativas

Dentro de un paquete puedes importar de dos maneras. Son dos alternativas, no dos líneas para escribir a la vez: escribes una o la otra y el resultado es el mismo.

# Desde cualquier punto del proyecto, empezando por el nombre del paquete:
from notas.almacenamiento import cargar

# Desde dentro del paquete notas, empezando por un punto:
from .almacenamiento import cargar

Usa la absoluta si el código está fuera del paquete; la relativa es más cómoda dentro de él, porque no repites el nombre del paquete y sigue funcionando si algún día lo renombras. No las pongas las dos: la segunda sobrescribe el nombre cargar de la primera.

La importación relativa solo funciona cuando el módulo forma parte de un paquete. Si ejecutas notas/almacenamiento.py directamente y aparece ImportError: attempted relative import, ejecuta el punto de entrada desde la raíz del proyecto (uv run python main.py) o usa uv run python -m notas.modulo (ejecutar como módulo, sin .py) cuando el módulo esté diseñado para ello.

5. Qué ocurre durante una importación

Python busca módulos en rutas conocidas, entre ellas el directorio del programa, el entorno virtual y las bibliotecas instaladas:

import sys

print(sys.path[0])
print(any("site-packages" in ruta for ruta in sys.path))

Salida esperada:

<una ruta, o una línea vacía>
True

La segunda línea siempre es True: tu entorno virtual tiene sus paquetes en una carpeta site-packages, y eso es lo que comprueba. La primera no se puede fijar, y por eso el bloque es de solo sintaxis: si ejecutas un script, sys.path[0] es la carpeta de ese script; si lo pruebas en el REPL o en un cuaderno, es una cadena vacía.

Esa variabilidad no es un defecto: es la evidencia de que Python busca módulos en rutas que dependen del punto de entrada, no de una lista fija. Por eso una importación puede funcionar desde un script y fallar desde otro sitio.

No añadas carpetas a sys.path para arreglar una estructura desordenada. Es una señal de que conviene ejecutar desde la raíz del proyecto o empaquetar mejor el código. sys.path se inspecciona para diagnosticar, no como solución habitual.

Importaciones condicionales

A veces una función opcional necesita una dependencia pesada. Se puede importar dentro de esa función y lanzar un mensaje claro si falta, pero no uses importaciones condicionales para esconder una dependencia necesaria del proyecto. Las dependencias y su instalación se estudian en 1.15.

6. Documentar un módulo

Un docstring de módulo explica su propósito y una función documenta su contrato:

"""Operaciones de almacenamiento de notas en JSON."""


def guardar(notas, ruta):
    """Guarda una lista de notas en la ruta indicada."""
    ...

La documentación debe decir qué entra, qué sale y qué errores previsibles puede producir. Las pruebas con assert y pytest se tratarán de manera sistemática en el capítulo 1.17; aquí céntrate en que el límite entre módulos sea claro.

Aplicación práctica: refactorizar el asistente

Compara estas responsabilidades:

Módulo Responsabilidad No debería hacer
almacenamiento.py Leer y guardar datos Pedir input()
validacion.py Comprobar valores Escribir JSON
main.py Coordinar la interacción Reimplementar cargar()

Una separación útil no consiste en crear muchos ficheros, sino en evitar que un cambio de interfaz obligue a tocar la lógica de datos. Como comprobación, cambia en main.py la ruta JSON por otra y verifica que almacenamiento.py no necesita cambios.

Errores frecuentes

  • Ejecutar un módulo interno desde la carpeta equivocada y obtener ModuleNotFoundError.
  • Olvidar if __name__ == "__main__": y ejecutar efectos secundarios al importar.
  • Usar from modulo import * y perder el origen de los nombres.
  • Crear importaciones circulares: a importa b y b importa a.
  • Importar desde el paquete el módulo equivocado por tener dos ficheros con el mismo nombre.
  • Cambiar sys.path en cada script en vez de corregir la estructura o el punto de ejecución.
  • Hacer que un módulo de almacenamiento dependa de la terminal o de un input.

Práctica de transferencia

  1. Separa el contador de palabras de 1.7 en texto.py y main.py.
  2. Añade una función resumir(notas) al módulo de almacenamiento sin imprimir dentro de ella.
  3. Ejecuta main.py y después importa resumir desde otro script; comprueba que importar no imprime mensajes inesperados.
  4. Crea un paquete conversiones con dos módulos y un __init__.py que exponga solo dos funciones.
  5. Dibuja la dependencia entre los módulos y señala una importación que sería circular si se añadiera sin criterio.

Producto evaluable

Amplía el asistente en un proyecto con esta estructura mínima:

asistente_ia/
├── main.py
└── asistente/
    ├── __init__.py
    ├── notas.py
    └── validacion.py

Entrega un script o cuaderno 09_modulos.py que:

  1. Importe funciones desde el paquete, sin duplicar su implementación.
  2. Ejecute una operación normal y una entrada inválida.
  3. Muestre que main.py es punto de entrada y que importar los módulos no arranca la aplicación.
  4. Incluya un diagrama o una tabla de dependencias y justifique una decisión de separación.

Criterio de aceptación: cada módulo tiene una responsabilidad reconocible, las importaciones funcionan desde la raíz del proyecto y no se modifica sys.path para ocultar un problema de estructura.

Formato de entrega: proyecto ejecutable, cuaderno marimo o scripts acompañados de una salida reproducible, interpretación y conclusión.

Resumen y referencia rápida

Necesitas Patrón
Importar un módulo import modulo
Importar un nombre from modulo import funcion
Punto de entrada if __name__ == "__main__":
Paquete actual from .modulo import nombre
API pública __all__ = ["nombre"]
Diagnosticar búsqueda sys.path

Siguiente: 1.13 · Programación orientada a objetos.