Entry Point para una CLI Python
La flexibilidad del ecosistema Python le ha permitido convertirse en uno de los lenguajes más utilizados. El coste de esa flexibilidad es que en contra del Zen of Python
There should be one, and preferably only one, obvious way to do it.
incluso la gente con experiencia encuentra confusión y falta de ejemplos canónicos en aspectos que deberían ser obvios cómo el empaquetado.
En este artículo tocamos uno de esos aspectos, el entry point para herramientas de consola (ClI).
Breve descripción de las "piezas" que entran en juego
__main__.py
Cuando en un python package (directorio que tiene dentro ficheros python), hay un fichero con el nombre __main__.py y ejecutamos el módulo desde la línea de comandos (python -m my_cli), es ese fichero el que se ejecuta. De la documentación oficial:
The
__main__.pyfile is used to provide a command-line interface for a package.
Si bien en este module no es necesario usar el idiom de __name__ == "__main__": es recomendable usarlo para evitar errores absurdos.
[proyect.scripts]
La sección [proyect.scripts] de pyproject.toml indica a la herramienta de instalación (pip, uv, ...) que debe crear un ejecutable con el nombre que se le indique apuntando (wrapper script, shim, ...) a un determinado módulo y función.
Es decir, es un mecanismo para la distribución de nuestra herramienta. Los usuarios no necesitarán ejecutar python -m my-cli, si no simplemente my-cli.
# El ejecutable `my-cli` apunta a la función `main()` de `my_cli/cli.py`
[project.scripts]
my-cli = "my_cli.cli:main"
Si instalamos en un entorno virtual el ejemplo anterior (python -m pip install -e .[all], uv sync, ...) se creará un fichero .venv/bin/my-cli con este contenido:
#!/tmp/example/my-cli/.venv/bin/python3
# -*- coding: utf-8 -*-
import sys
from my_cli.cli import main
if __name__ == "__main__":
if sys.argv[0].endswith("-script.pyw"):
sys.argv[0] = sys.argv[0][:-11]
elif sys.argv[0].endswith(".exe"):
sys.argv[0] = sys.argv[0][:-4]
sys.exit(main())
Si se instala a nivel global sería parecido.
Fijémonos en que:
- Apunta a una ruta de python absoluta. De modo que si lo ejecutamos fuera de un virtualenv
/tmp/example/my-cli/.venv/bin/my-cliestará apuntando al python correcto. - Al activar un virtualenv se modifica
"${PATH}"incluyendo cómo primera entrada/tmp/example/my-cli/.venv/bin/, así que el ejecutable estará directamente disponibel cómomy-cli - Importa el módulo y función que hemos indicado
- Es multiplataforma. En linux podremos usar
my-cli, en windowsmy-cli.exe
__init__.py
Es un fichero que marca un directorio cómo un python package. Su contenido se ejecuta siempre que se hace un import del paquete. No debería ser usado para escribir lógica, ni cómo entry point para un ejecutable.
Mi forma preferida
Las piezas se pueden combinar de muchas formas. Algunas combinaciones serán más correctas que otras, según que criterios o necesidades se tengan en cuenta.
Mis criterios son:
- src-layout
- Siempre instalar el paquete, sea para usarlo o desarrollarlo
- Asumir que el paquete puede ser distribuido
- Minimizar tiros-en-el-pie, errores absurdos y subtle bugs
Con lo que llegamos a la siguiente estructura:
# my-cli/src/my_cli/__init__.py
__version__ = "1.0.0"
# my-cli/src/my_cli/lib.py
def do_things():
print("do things")
# my-cli/src/my_cli/cli.py
from my_cli.lib import do_things
def main():
print("Argument parsing logic goes here")
do_things()
# my-cli/src/my_cli/__main__.py
from my_cli.cli import main
if __name__ == "__main__":
main()
Algunas dudas
Tengo algunas dudas con esta aproximación que modifico según el caso:
cli.mainocli.cli. El mejor nombre para esta función también me causa dudas. Depende del naming del resto código, y de si la herramienta es fundamentalmente una cli, una librería o ambas.- Cuando se usan librerías cómo Typer o Click y subcomandos la estructura de directorios debería ser algo distinta pero la idea base es la misma.
- Es realmente necesario proporcionar un
__main__.py. Significa duplicar lógica para un uso muy concreto sólo necesario durante el desarrollo.
Prácticas "incorrectas"
Usar __init__.py
Al importar nuestro paquete (import my_tool) estaremos:
- Polucionando el espacio de nombres con el
cli.main. Podríamos usar__all__, pero eso queda para otro día. - Fomentando antipatrones
- Corriendo el riesgo de side effects indeseados.
# WRONG
# src/my_cli/__init__.py
from my_cli.cli import main
__version__ = "1.0.0"
if __name__ == "__main__":
main()
Jugar con el nombre del ejecutable
En pyproject.toml se puede asignar un nombre al ejecutable. Salvo que haya un motivo realmente bueno, cómo tener más de un ejecutable, debe tener el mismo nombre que el paquete para evitar confusiones.
# WRONG
# pyproject.toml
[project.scripts]
# Asignamos el nombre my-tool al ejecutable
my-tool = "my_cli.cli:main"
Ejecutar directamente cli.py
Salvo scripts muy pequeños, ejecutar directamente un módulo Python debería ser considerado un antipatrón. Obligar a instalar el paquete y ejecutar los módulos ahorra problemas, sobre todo con los imports. Por ello no permitiremos la ejecución directa del módulo.
# WRONG
# src/my_cli/cli.py
from my_cli.lib import do_things
def main():
print("Argument parsing logic goes here")
do_things()
# Permitimos ejecutar directamente este módulo con `python src/my_cli/cli.py`
if __name__ == "__main__":
main()
Apuntar a __main__.py desde project.scripts
Parece una buena idea, porqué un cambio grande en la herramienta, que toque cli.py, puede obligar a cambiar tanto el fichero __main__.py cómo el pyproject.toml. Si pyproject.toml apunta directamente a __main__.py sólo hay que hacer el cambio en un sitio.
Pero no lo es.
__main__.pydebería estar reservado para ejecutar conpython -m my_toolpor Separation of Concerns.- El stack trace al usar
my-toolirá directamente acli.pysin pasar con__main__.pyque es sólo un wrapper. - Los imports se vuelven confusos, y podemos acabar con dependencias circulares. En este ejemplo concreto, aunque podría ser escrito de otra forma, "main" es en realidad
cli.main, no__main__.main. __main__tiene dos significados distintos en python, lo que lleva a confusión.- Evitamos indirecciones. Con leer
pyproject.tomlpodemos saltar al código real, y el shim también queda más limpio. - Es una práctica más habitual (black, pytest, pip).
- Si el proyecto crece y proporcionamos más ejecutables, el resultado es más limpio.
[project.scripts]
my-tool = "my_cli.cli:main"
my-tool-db = "my_cli.commands.database:main"
my-tool-serve = "my_cli.commands.server:main"
Referencias
- Top-level code environment en la documentación oficial de Python
- Entry Point en la documentación de setuptools
- Why do script entrypoints require a function be specified?