Organización de un proyecto de Python

Paquetes, módulos, __init__.py, __main__.py e imports absolutos y relativos

Aunque es perfectamente válido incluir todo nuestro código en un sólo fichero .py, tarde o temprano cualquier proyecto nos obligará a pensar en cuál sería la estructura ideal de paquetes y módulos.

Una de las muchas fortalezas de Python (y por lo que se percibe quizás como un lenguaje con una curva de aprendizaje bastante asequible) es que resulta muy sencillo crear un simple fichero .py, escribir algo de código y ejecutarlo con una sola instrucción como python fichero.py. Sin embargo, esta suerte de “scripting” no escala muy bien para abordar un proyecto completo con garantías.

Además, y ahondando en ese concepto de “scripting” que Python nos ofrece, no tenemos ni porqué programar bajo el paradigma de la programación orientada a objetos. Es cierto que en Python todo es un objeto, desde los tipos primitivos (str, int, bool, etc) hasta los tipos built-in (list, dict, tuple, set) pasando por una función o un módulo. Sin embargo, es perfectamente válido escribir un programa en Python sin usar la palabra reservada class, esto es, sin ni siquiera saber que es una clase.

Python es object-based en vez de object-oriented. Head First Python, 2nd Edition

El scripting por sí mismo no es malo. Hay que pensar en el amplio uso que se da a Python: desde un script para automatizar una tarea de IT, pasando por un notebook interactivo para explorar y analizar datos, hasta complejas aplicaciones web que requieren una gran cantidad de funcionalidad. En todas ellas, con o sin OOP, Python sirvió y cumplió, nada que objetar.

No obstante, pensando más en aplicaciones de cierto calado, el propósito de este post es explicar las distintas opciones que tenemos a la hora de organizar la estructura de nuestro proyecto.

Las reglas iniciales del juego son:

  • Python reconoce un directorio como un paquete si tiene dentro un fichero __init__.py.
    • No es necesario que tenga ningún contenido, con que esté es suficiente.
  • Un paquete tendrá normalmente módulos pero también puede contener otros paquetes (que bien podríamos denominar subpaquetes).
  • Un módulo es un fichero .py que contiene código (funciones, clases, etc.)

Si creamos un directorio y no añadimos un fichero __init__.py, para Python sería un namespace package que, aunque tiene su razón de ser, es bastante excepcional. Con seguridad, el 99.9% de las veces la decisión correcta es crear el fichero __init__.py.

Normalmente, el fichero __init__.py estará vacío, pero es cierto que nos permite incluir código de inicialización del paquete.

Veamos primero un ejemplo con ficheros __init__.py vacíos (y donde main.py está fuera del paquete para facilitar el resto de los ejemplos, aunque en la propuesta final estará dentro de a_package):

project\
    a_package\
        __init__.py
        a_module.py
        a_subpackage\
            __init__.py
            another_module.py
    main.py
# main.py
import a_package.a_module
import a_package.a_subpackage.another_module

Con independencia de que resulte más o menos verbose la forma escogida de importar los módulos (la instrucción import admite múltiples variaciones), lo que queda claro es que creamos paquetes y módulos buscando organizar nuestro código bajo la premisa de la modularidad.

Respecto a main.py, cabe mencionar que no tiene un significado especial para Python. Podríamos haberlo llamado foo.py y todo seguiría igual. La confunsión suele venir porque si el fichero se llamara __main__.py (se parece mucho pero no es lo mismo) sí que es algo que Python entiende como un caso especial. Hablaremos de él más adelante.

Como dijimos antes, __init__.py puede incluir código de inicialización del paquete. Por ejemplo, nos permitiría ejecutar código la primera vez que se importe algo del paquete o también podría servir para exponer de forma ordenada la API interna del paquete a un consumidor.

# a_package\__init__.py
import a_package.a_module as my_first_module
import a_package.a_subpackage.another_module as my_second_module

print("a_package was loaded")
# main.py
import a_package  # import the package

# 'a_package was loaded' was printed out

a_package.my_first_module.a_function()
a_package.my_second_module.another_function()

Algo importante que entra en juego cuando usamos __init__.py es la variable __all__. Tiene un caso de uso muy concreto y es cuando se usa la instrucción from a_package import *. Por defecto, y asumiendo que no hay que usar * al importar (no lo digo yo, lo dice PEP 8, Wildcard imports (from <module> import *) should be avoided…), __all__ nos permite definir qué se exportará y qué no (en vez de todo sin filtro alguno). Si no usamos __all__, se exportará todo el espacio de nombres disponible en __init__.py (excepto lo que comience por guion bajo que por convención se considera privado).

# a_package\__init__.py
import a_package.a_module as my_first_module
import a_package.a_subpackage.another_module as my_second_module

__all__ = ['my_first_module']  # We only export my_first_module
# main.py
from a_package import *

my_first_module.a_function()
my_second_module.another_function()  # It will fail because it's not in __all__

Como verás, __init__.py da mucho juego en relación a como exponer la API de un paquete al exterior y ofrece un buen abanico de posibilidades. Mi recomendación es que te leas los siguientes posts donde se explica mucho más en detalle como usar __init__.py y darle un sentido.

En What’s __init__ for me? se usa la metáfora de una tienda de comestibles y se presentan varias opciones para organizarla de cara al usuario final, el comprador, léase el developer. Es decir, se muestran ventajas y desventajas de las distintas opciones que tenemos para importar cosas en __init__.py y como un usuario final puede usarlas.

En What is __init__.py? and what should I put in it? se habla sobre los distintos usos que podemos dar a __init__.py

Si usas __init__.py para organizar tu API, sería buena idea usar __all__ para ser explícito y cumplir con la regla de --no-implicit-reexport de mypy.

Aunque antes se mencionó de pasada, en Python no existe la visibilidad como una regla estricta. Es decir, no hay una palabra clave private ni ninguna otra similiar. En Python todo es público. Sin embargo, existe la convención de que todo lo que comience por un guion bajo (_) es privado. Pero es eso, una convención, nadie te obliga a usarlo… aunque todo el mundo la sigue.

Si usamos clases, un guion bajo sería el equivalente a protected en el contexto de una clase hija o derivada, puesto que la misma puede sobrescribir el método heredado. Si queremos realmente que sea private en vez de protected tenemos que usar dos guiones bajos (__) y con un poco de magia a cargo de name mangling Python convertirá nuestro método a _ClassName__Attribute donde ya sí, sería mucho más difícil (aunque no imposible) sobrescribir el método en una clase hija.

Como ves, el guion bajo es algo muy importante en Python, de nuevo en el PEP 8 nos informan sobre ello. Sinceramente, leer PEPs no es algo que uno haga de buena mañana, pero si sólo pudieses leer uno, PEP 8 es tu PEP.

PosiciónEjemploUso
_single_leading_underscore_fooUso interno, privado.
single_trailing_underscore_foo_Evitar conflictos con palabras reservadas del lenguaje, built-in o para evitar ocultar una variable de un ámbito superior. Por ejemplo, class_, len_, etc
__double_leading_underscore__fooName mangling. private en clase derivada.
__double_leading_and_trailing_underscore____foo__Un método “mágico”.
Single underscore_Un nombre de usar y tirar para una variable. def foo(_): return True

Personalmente, la palabra “mágico” no me gusta (y no soy el único) porque parece que es algo sólo para expertos o gurús. Me gusta más dunder (que es una forma sencilla de pronunciar double underscore). Por ejemplo, “el método dunder foo…”.

La convención _single_leading_underscore se usa incluso en el nombre de los módulos. Por ejemplo _foo.py sería un módulo privado. La propia Standard Library lo usa (a veces). En mi opinión, no creo haya un consenso fuerte en el uso de esta práctica.

De vuelta con __main__.py, es importante entender ese “comúnmente usado” snippet de código que aparece en cada main.py que se precie:

# main.py
def main():
    # Your program goes here
    pass


if __name__ == '__main__':
    main()

Lo importante es saber que un módulo puede ser ejecutado o cargado. Si es ejecutado con python main.py o bien cargado con python -m main entonces __name__ valdrá el literal "__main__", es lo que llaman top-level code environment. Por otro lado, si se carga como un módulo a través de un importvaldrá su nombre de módulo precedido de los paquetes en los que esté incluido separados por punto. Por ejemplo, para a_package/a_module.py, __name__ valdría "a_package.a_module".

Con esta información, lo que realmente estamos diciendo es lo siguiente:

if __name__ == '__main__':
    # Only run the main function if this module is being run directly with `python main.py` or `python -m main`
    main()

Con python -m <ruta_a_módulo> estamos ejecutando un módulo de un paquete como un script.

Quizás no sea muy común ejecutar un módulo desde línea de comandos, pero a veces algunos paquetes están pensandos para ser ejecutados así de forma adicional, por ejemplo timeit.

Sabiendo esto, ¿Por qué entonces se usa este snippet? Para evitar que nuestra aplicación se ejecute 2 veces… cierto es que es un corner-case, pero mejor prevenir que curar. Para verlo mejor hagamos un ejemplo “forzado” (lo sé) con 2 ficheros, main.py y foo.py.

# main.py
import foo

print("__name__ for main.py", __name__)


def a_useful_function():
    pass

# The trick for this example is to comment / uncomment this next line, if __name__ == '__main__': and see what happens
if __name__ == '__main__':
    print("This is my entry point and it should be executed only once time")
# foo.py
import main


def foo():
    # I need to use a function that it's in main.py, but main.py is importing me also
    main.a_useful_function()

Con nuestro amigo if __name__ == '__main__': hemos salvado el match-ball. La salida por consola es la siguiente:

1
2
3
__name__ for main.py main
__name__ for main.py __main__
This is my entry point and it should be executed only once time
  • 1 es main cuando fue importado por foo.py
  • 2 es __main__ cuando fue ejecutado con python main.py

Ahora (y por algún extrano motivo), alguien comenta (o peor, no incluyó de inicio) la línea if __name__ == '__main__':, la salida es muy distinta:

__name__ for main.py main
This is my entry point and it should be executed only once time
__name__ for main.py __main__
This is my entry point and it should be executed only once time

Ups, acabamos de ejecutar todo nuestro maravilloso programa 2 veces 😒, no mola.

Retomando el hilo del fichero especial __main__.py ¿Cuándo se ejecutará “automáticamente” este fichero? Cuando se use python -m <package> (en vez de python <file.py>). En este caso, Python ejecutará (si lo encuentra) un fichero __main__.py dentro del paquete/directorio especificado. Se usa, normalmente, para dotar a nuestros paquetes de un CLI y suelen ser ficheros pequeños que parsean la parámetros de la línea de comandos y ponen a trabajar a otros módulos. Cabe mencionar que no se suele usar el snippet if __name__ == '__main__': en el fichero __main__.py.

Para hacer la prueba, bastaría con agregar el fichero __main__.py en a_package\a_subpackage y ejecutar python -m a_package.a_subpackage.

Cambiando de tercio, otra decisión importante a tomar en cualquier proyecto es el estilo de import a usar.

Para conocer las diferencias entre un import absoluto y uno relativo explícito (también existe el relativo implícito pero se desaconseja su uso), te recomiendo leer el siguiente post, Absolute vs Relative Imports in Python. Además, de nuevo en PEP 8 hay una sección dedicada al tema.

Un import relativo explícito comienza por un punto, si no lleva punto - y no es absoluto - es implícito.

import a_package.a_module desde main.py es un import absoluto porque incluye toda la ruta desde la raíz del proyecto. Python es capaz de encontrar en sys.path el paquete a_package porque el directorio donde esté el fichero .py siendo ejecutado (en nuestro caso, main.py ) es agregado automáticamente a sys.path. De este modo, resuelve satisfactoriamente a_package porque está dentro de project\. Fijarse que se agrega el directorio donde esté el fichero .py, no el directorio actual desde donde estamos ejecutando el comando. Es decir, daría igual hacer python main.py en project\ que python ..\main.py estando en la carpeta project\a_package\. Con ambos comandos lo que terminará en sys.path será el directorio donde esté main.py, que es project\).

Un import absoluto es sencillo. Estés donde estés, siempre usa la ruta completa al módulo o paquete comenzando por la raíz del proyecto.

Todo esto es sencillo de comprobar volcando sys.path por consola y ejecutando main.py desde distintas carpetas del proyecto.

print('\n'.join(sys.path))

Por otro lado, los import relativos permiten cargar un módulo con una ruta relativa al actual “nombre del módulo”. En este contexto, el nombre del módulo no es su nombre de fichero y el directorio donde esté. El nombre del módulo es o bien __package__ + '.' + __name__ si __package__ no es None, o bien directamente __name__. Si por ejemplo el nombre del módulo es a_package.a_module y se encuentra un from .a_subpackage import another_module lo podrá resolver porque .a_subpackage es como si hubieramos escrito from a_package.a_subpackage import another_module.

Hay una regla de oro que funciona muy bien y es “tiene que haber tantos puntos en el nombre del módulo como puntos en la ruta relativa del import”.

Por cierto, para un módulo ejecutado ("__main__" en __name__ y None en __package__, es decir, python <fichero>.py), olvídate de import relativos. Es imposible resolver algo relativo a “ningún” paquete porque no se tiene información sobre el nombre del módulo. Ahí es cuando probablemente tengas el siguiente error ImportError: attempted relative import with no known parent package, que básicamente viene a decir que segun el nombre del módulo, Python no sabe a que paquete pertenece el módulo actual y por ende, poca ruta relativa puede resolver. Si hacemos python -m directorio.main.py si funcionaría porque __package__ sería directorio. Otro error muy típico es “pasarte con los puntos”. Si usamos más puntos de los que hay en el nombre del módulo, Python nos dará el siguiente error: ImportError: attempted relative import beyond top-level package. Básicamente, no seguiste la regla de oro y usaste puntos por encima de tus posibilidades. También podemos concluir que puedes usar un import relativo siempre dentro del contexto de tus paquetes ancestros (con cualquier número de puntos), pero no puedes alcanzar la raíz del proyecto y luego querer moverte a otro paquete hermano a nivel superior. Es decir, desde un módulo de a_package no se podría hacer un import relativo a un módulo de other_package.

project\
    a_package\
        __init__.py
        ...
    other_package\
        __init__.py
        ...
    main.py

Para ver en acción el valor de los nombres de módulo, hagamos un ejemplo:

# main.py
print("main.py =", __package__, ",",  __name__)

import a_package.a_module
# a_package\a_module.py
print("a_module.py =", __package__, ",",  __name__)

from a_package.a_subpackage import another_module
# a_package\a_subpackage\another_module.py
print("another_module.py =", __package__, ",",  __name__)

Con python main.py el resultado es el siguiente:

main.py = None , __main__
a_module.py = a_package , a_package.a_module
another_module.py = a_package.a_subpackage , a_package.a_subpackage.another_module

Sin embargo con python .\a_package\a_module.py nos dará un error porque no puede resolver from a_package..., y esto es porque se agregó a sys.path el directorio project\a_package\, y no project\ como en el caso anterior.

Si está contra la espada y la pared, se puede modificar sys.path en tiempo de ejecución… cosa que, por otro lado, no me gusta e intento evitar siempre. No obstante, con el siguiente código se añadiría el directorio padre de a_package\ a sys.path.

# a_package\a_module.py
import os
import sys

if __name__ == '__main__':
    # Add the parent directory to the path
    sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# ...

Finalmente, y en cuanto a directorios recomendados para un proyecto, me gusta la siguiente estructura donde se opta por la vía de Tests outside application code según las buenas prácticas de pytest.

    YourProject\
        src\
            a_package
                __init__.py
                main.py
        tests\
            ...

Si trabajas con PyCharm, usa Mark Directory as > Sources Root en la carpeta src\ y así se agregará a sys.path y PyCharm estará conforme con imports del tipo import a_package y similar.

En cuanto a pytest es necesario configurar pythonpath para que esté al tanto de la carpeta src\. Se estás usando pyproject.toml:

[tool.pytest.ini_options]
pythonpath = "src/"

Si quieres ahondar en el tema, aquí te dejo un enlace a una presentación donde se explica esto y mucho más. ¡Es oro puro!

Por último, si quieres una plantilla de cookiecutter lista con soporte adicional para pyspark, inyección de dependencias e incluso GitHub Actions que despliegan en un feed de Azure DevOps (todo ello opcional), la puedes encontrar en https://github.com/panicoenlaxbox/cookiecutter-pythonpackage

Un saludo!


Ver también