True
Saltar a contenido

Index

Herramientas de Lint y Format para Markdown

Note

No te pierdas la continuación de este artículo hablando de la herramienta rumdl

Llevo bastante tiempo usando markdownlint-cli2 y prettier para linting y formatting de Markdown, pero no tenía un análisis de las herramientas disponibles, para ver si había mejores opciones.

Requisitos Deseables

  • Integración con el IDE. Una buena extensión para VSCode y derivados
  • Integración con pre-commit (oficial en el repo, no sólo ejecución local)
  • Una sola herramienta mejor que varias herramientas. En todo caso el linter y el formatter deben ser compatibles.
  • Bien mantenida, popular, ...
  • Rápida
  • Rust > Python > Javascript
  • Prefiero herramientas en lenguajes que generan binarios como Rust porqué son más fáciles de instalar y mantener. Luego en Python porqué conozco mejor el ecosistema
  • Formato:
  • Mucho Markdown es para LLMs o producido por LLMs. Así que prefiero un estilo que concuerde.
  • Que se lleve bien con MkDocs, que tiene su propio sabor (python-markdown, y python-markdown-extensions)

Aunque preparando el artículo me entero de que MkDocs está deprecated y Zensical del mismo equipo usará en el futuro (1) CommonMark

  1. 🙏 !Gracias!

Herramientas

mdformat

mdformat es un formatter escrito en Python.

  • Usa markdown-it-py cómo parser.
  • 643 estrellas
  • Soporta CommonMark, GFM, MysT
  • Tiene plugins para soportar casos particulares como Admonitions, formatear código dentro de fenced blocks, ...
  • Opinionated. Pocas opciones de configuración
  • Soporta MkDocs a través de plugin:
  • Aunque hay que probar bien.
  • Plugin no oficial para vscode.
  • Tiene soporte para pre-comit

No me acaba de convencer.

prettier

Prettier es el estándar para formatear en el ecosistema JavaScript.

  • Usa remark-parser como parser
  • 50k estrellas
  • Soporte para CommonMark, GFM y MDXv1
  • Muy buena integración con el IDE
  • Es opinionated. La única opción específica markdown es prose-wrap
  • No hay soporte oficial para pre-commit, ni para prek

Una opción que nunca es mala, aunque puede no ser la mejor.

markdownlint

Bajo este nombre encontramos varias herramientas de lint:

La que nos interesa es markdownlint-cli2.

  • Markdown/CommonMark
  • pre-commit, github action, extensión para vscode.
  • Distintos formatos de salida (xml, json, consola, con colores, ...)
  • La configuración es algo confusa, pero los ficheros .markdownlint-cli2.* permiten configurar tanto la CLI, cómo la extensión para code, cómo la librería
  • El linter usa por debajo el parse markdown-it.

Un artículo sobre la herramienta.

A pesar de las confusiones de nombres, y la documentación dispersa no es una mala opción.

remark

Remark no es una herramienta si no un ecosistema en torno a Markdown que a su vez forma forma parte de unifiedjs.

Por defecto trabaja con ConmmonMark pero tiene plugins para otras versiones.

Es muy configurable, tanto, que entender bien cómo funciona, escoger los plugins y configurar las opciones adecuadas se vuelve complicado.

Sin soporte oficial para pre-commit.

La extensión de remark-lint para vscode que aparece en el README no se actualiza desde 2018. Hay otra extensión que parece oficial que en el momento de escribir esto lleva más de año y medio (Abril/2024) sin actualizarse.

  • remark-parse es la librería que convierte markdown a AST. Es usada también por prettier.
  • remark-stringify. Es la librería que se encarga de convertir el AST a Markdown. Cuando formateamos Markdown remark-parse lo convierte a AST, y remark-stringify lo convierte de nuevo a Markdown. Las reglas que queremos para el formatter son las que admita esta librería más plugins.
  • remark-lint. Es un monorepo que contiene la librería básica de linting y un montón de reglas que están por separadas o en conjuntos llamados "presets". Pero no es una herramienta, el uso de la librería es a través de remark-cli. Cuando instalamos un preset el paquete base remark-lint va cómo dependencia. Los presets más habituales:
  • remark-preset-lint-consistent — rules that enforce consistency
  • remark-preset-lint-markdown-style-guide — rules that enforce the markdown style guide
  • remark-preset-lint-recommended — rules that prevent mistakes or stuff that fails across vendors.
  • remark-cli, es la herramienta de línea de comandos para llevar a cabo operaciones.
  • plugins para casi todo lo que podamos imaginar. Otras versiones de markdown cómo remark-gfm, manipulaciones cómo remark-toc, linting cómo remark-lint, ...
Un ejemplo de instalación y uso
# Instalar la línea de comandos genérica
npm install --save-dev remark-cli

# Instalar plugins cómo remark-toc o un conjunto de reglas
npm install --save-dev remark-preset-lint-markdown-style-guide remark-toc

# Format de un fichero
remark --output readme.md

# Format de un fichero añadiendo el TOC
remark --output --use remark-toc readme.md

# Lint de todos los ficheros acorde a remark-preset-lint-markdown-style-guide
remark --use remark-preset-lint-markdown-style-guide .

# Format de todos los ficheros markdown en el directorio actual
remark . --output

# Lint de todos los ficheros markdown en el directorio actual
remark .
Un ejemplo de configuración
// .remarkrc.yaml
plugins:
  # Check that markdown is consistent.
  - remark-preset-lint-consistent
  # Few recommended rules.
  - remark-preset-lint-recommended
  # Generate a table of contents in `## Contents`
  - - remark-toc
    - heading: contents
settings:
  bullet: "*"
  emphasis: "_"
  strong: "*"

Herramientas Descartadas

Biome

Biome es la alternativa a prettier y eslint escrita en rust, pero todavía no implementa todas las reglas ni todos los lenguajes que soporta prettier. No soporta Markdown por ahora.

PyMarkdown

PyMarkdown es un linter escrito en Python que cumple pocos de los requisitos.

  • Usan su propio parser.
  • 109 estrellas
  • Soporta CommonMark y GFM
  • Tiene soporte para pre-commit
  • No parece tener extensión para vscode, ni ningún otro IDE
  • Tienen muchas opciones y reglas y es muy configurable
dprint

dprint no es un proyecto al que le hubiera prestado mucha atención si no fuera porqué lo usan Deno y Helix.

Es un "framework" para formatting escrito en Rust que soporta muchos lenguajes a través de plugins, entre ellos Markdown. Por ejemplo tienen un plugin para ruff para formatear Python.

Para markdown usa un parser centrado en CommonMark con soporte parcial para GFM. Las reglas de formato son poco configurables. Parece tener integración con pre-commit (no oficial) y vscode, pero no muy mantenida:

Bugs a los que prestar atención:

Otras referencias

Conclusiones

Prettier y Markdownlint son las mejores opciones en este momento. Habrá que estar atentos a la evolución de otras herramientas cómo dprint, biome u Oxc, y también a lo que salga de Zensical.

Si manipular el Markdown mediante línea de comandos también es de interés remark es una opción a estudiar.

Bash debug mode

Depurar un script de bash suele consistir en insertar un montón de echo que luego hay que borrar.

Un método mejor es usar set -x, que activa lo que podríamos llamar bash debug mode.

de la documentación bash

-x. Print a trace of simple commands, for commands, case commands, select commands, and arithmetic for commands and their arguments or associated word lists to the standard error after they are expanded and before they are executed. The shell prints the expanded value of the PS4 variable before the command and its expanded arguments.

Esta opción hace un print de cada comando del script a stderr antes de ejecutarlo.

Los parámetros (parameters) se expanden antes del print por lo que veremos los valores reales (arguments).

Podemos simplemente añadirlo al script cuando estemos depurando, y eliminarlo después. O, incluir la lógica en el propio script mediante parámetros y variables de entorno. Lo bueno de la variable de entorno es que podríamos tener varios scripts que la compartan de modo que activemos el modo debug para todos los scripts a la vez

#!/usr/bin/env bash

DEBUG="${GLOBAL_DEBUG_MODE:-false}"

while [[ $# -gt 0 ]]; do
    case $1 in
        --debug) DEBUG=true ;;
    esac
    shift
done

"${DEBUG}" && set -x

a=5
echo "${a}"
echo "bar"

Salida:

$ GLOBAL_DEBUG_MODE=true ./my-script.sh

+ a=5
+ echo 5
5
+ echo bar
bar

Dos trucos adicionales en los que fijarse que nos permiten un poco de magia extra

# Use '2$>' instead of '2>' to combine stderr and stdout
$ PS4='\D{%F:%T} >> ' ./my-script.sh --debug 2> debug.log

5
bar

$ cat debug.log

2026-02-08:19:30:51 >> a=5
2026-02-08:19:30:51 >> echo 5
2026-02-08:19:30:51 >> echo bar

Bash: Variables indefinidas (unset) vs vacías (empty)

Bash es un lenguaje particular respecto a las variables que no han sido definidas previamente.

En Python, por ejemplo, las reglas son claras: una variable debe estar declarada antes de usarse. Si esa variable no existe, el intérprete lanzará un error.

>>> print (my_var)
Traceback (most recent call last):
  File "<python-input-0>", line 1, in <module>
    print (my_var)
           ^^^^^^
NameError: name 'my_var' is not defined

>>> my_var="foo"
>>> print (my_var)
foo

En bash, por defecto, no se distingue entre una variable vacía y una indefinida. Al acceder a una variable no definida, bash no lanza errores y no detiene el script. Simplemente asume la asume cómo un empty string.

Esto, que parece cómodo para pequeños scripts, es también causa de muchos desastres: rm -rf "${build_dir}/".

Empty vs Null

En este artículo usaremos exclusivamente el termino empty variable o variable vacía.

Pero para bash no hay diferencia práctica entre null y empty. A veces también se habla de ese valor cómo empty string o null string

En la práctica el valor de estas dos variables es el mismo:

explicit_empty_or_null_string=""
implicit_empty_or_null_string=

Empty vs Unset

Pero, aunque bash intente disimularlo, si hay diferencias entre empty o null y unset:

  • empty: La variable existe, tiene un espacio asignado en memoria, pero su contenido es una cadena de longitud cero.
  • unset: La variable no existe en el entorno actual. No tiene un espacio de memoria asignado.

Sin "protecciones", ambos tipos se comportan igual al expandirse:

#!/usr/bin/env bash
# Sin 'set -u'

unset explicit_not_existent_var # variable indefinida
explicit_empty_string=""  # variable vacía
implicit_empty_string=    # variable vacía

# En los cuatro casos se imprimirán líneas vacías
echo "A: ${implicit_no_existent_var}"
echo "B: ${explicit_not_existent_var}"
echo "C: ${explicit_empty_string}"
echo "D: ${implicit_empty_string}"

Unbound variables

La cosa cambia cuando activamos el modo de bash de protección ante unbound variables

unset vs unbound

No hay diferencias a nivel práctico entre el término unset y el término unbound en bash y se pueden usar cómo sinónimos. Lo que sucede es que al usar una variable no definida bajo set -u el mensaje de error (bash: foo: unbound variable) hace referencia a unbound,mientras que los comandos en sí hacen referencia a unset

Cuando usamos set -u (o set -o nounset) cambiamos "al modo protección". Una práctica que forma parte del llamado bash strict mode y que debería usarse en el 95% de los casos.

de la documentación bash

Treat unset variables and parameters other than the special parameters '@' or '', or array variables subscripted with '@' or '', as an error when performing parameter expansion. An error message will be written to the standard error, and a non-interactive shell will exit.

Siguiendo el ejemplo anterior:

#!/usr/bin/env bash

set -u

unset explicit_not_existent_var # variable indefinida
explicit_empty_string=""  # variable vacía
implicit_empty_string=    # variable vacía

echo "A: ${implicit_not_existent_var}" # bash: implicit_no_existent_var: unbound variable
echo "B: ${explicit_not_existent_var}" # bash: explicit_no_existent_var: unbound variable
echo "C: ${explicit_empty_string}" # imprime una cadena vacía
echo "D: ${implicit_empty_string}" # imprime una cadena vacía

Operaciones útiles

Hay varias "operaciones" que es útil conocer para validar estas variables.

La primera es:

  • noop :. Llamada a veces operación null, permite evaluar las variables si que se ejecute su resultado. Veremos su utilidad en ejemplos posteriores.

Conditional Expressions

Lo más básico es usar la expresiones condicionales del [[ compound command y los builtin commands test y [

De la documentación de expresiones condicionales:

  • -v varname. True if the shell variable varname is set (has been assigned a value). If varname is an indexed array variable name subscripted by @ or *, this returns true if the array has any set elements. If varname is an associative array variable name subscripted by @ or *, this returns true if an element with that key is set.
  • -z string. True if the length of string is zero.
  • -n string. True if the length of string is non-zero.

Usaremos estas expresiones dentro de un if o con operadores cómo && o ||. El que en general debemos usar es -v dado que -z y -n dan error en modo set -u con variables indefinidas.

Danger

-v espera un nombre de variable, no se debe poner el $. Si estamos trabajando con referencias hay que usar -R.

A modo de ejemplo

set -u
unset foo

if [[ -z "${foo}" ]]; then echo "'foo' is not set"; exit 1; fi # bash: foo: unbound variable
[[ -z "${foo}" ]] && echo "'foo' is not set" && exit 1 # bash: foo: unbound variable

if ! [[ -v foo ]]; then echo "'foo' is not set"; exit 1; fi # 'foo' is not set
[[ -z foo ]] || echo "'foo' is not set" && exit 1 # 'foo' is not set
[[ -z foo ]] || foo='DEFAULT_VALUE' # DEFAULT_VALUE is assigned to foo

Parameter Expansion

Para asignar valores por defecto o "fallar pronto" si una variable está vacía el parameter expansion de bash es más elegante que las expresiones condiciones

  • Valor por defecto (safe fallback) :-. Si la variable es unset o empty aplica un valor por defecto sin modificar la variable original.
  • Asignación por defecto :=. Si la variable es unset o empty asigna un valor por defecto a la variable original.
  • Fallo Temprano :?. Si la variable es unset o empty, e independientemente de haber usado set -u se aborta el script con un mensaje.
# Si 'nombre' no existe o está vacío, usa "Mundo".
echo "Hola ${nombre:-Mundo}"

# Asigna "rm" a FAVORITE_COMMAND si no estaba definido.
# Usamos noop (:) para que Bash evalúe la expresión sin ejecutar el resultado. Sin `:`, ejecutaría el `rm` o lo que contenga la variable FAVORITE_COMMAND
: ${FAVORITE_COMMAND:=rm}

# Si build_dir no está seteado, aborta imprimiendo el mensaje.
rm -rf "${build_dir:?Error: Directorio no definido}/"

Algunas referencias extras

Conclusiones: Guía de estilo

Los scripts en bash son potentes y flexibles pero es fácil que el código sea difícil de leer o con bugs ocasionales pero catastróficos.

Dentro de mis normas para bash en lo referente a variables vacías y no definidas están:

Usar set -u. Hay pocas situaciones en que este modo no sea el correcto. Y en partes concretas de un script se puede desactivar y volver a activar.

#!/usr/bin/env bash

set -u

echo "Start"

set +u
echo "Something weird related to unbound variables"
set -u
echo "Come back to safety"

Usar :- para asignar valores por defecto. No uso el comando de asignación :=. Por nada en especial, simplemente me permite reducir la cantidad de formas distinta de hacer lo mismo. No se pueden usar con $1.

#!/usr/bin/env bash

set -u

"${foo:=World}" # error, intentará ejecutar el comando `World`
: "${1:=World}" # error, no se puede asignar a $1
: "${foo:=World}" # No da error, pero "reducimos la API a conocer"

# Me gustan más estas soluciones
foo="${1:-World}"
foo="${foo:-World}"

die(){
  # Call like `die "File not found"` or `die`
  local error=${1:-Undefined error}
  echo "$0: $LINE $error" >&2
  exit 1
}

# Si no queremos que salte unbound pero no nos preocupa que sea empty, podemos dejar el
# valor por defecto vacío
info() {
    # Will print the message `info "this is a message"` or an empty line `info`
    local msg="${1:-}"
    echo "${msg}"
}

Usar :? para comprobar cuando una variable es vacía o indefinida.

Ejemplos:

#!/usr/bin/env bash

set -u

user="${1:?Mandatory parameter for 'user' is missing}"

build_dir_path=$(find . -type d -iname 'build_dir')
: ${A:?'build_dir' folder is not found}

: ${VIRTUAL_ENV:?"virtualenv should be activated before continue"}

# Prefiero la versión anterior a estas
[[ -v VIRTUAL_ENV ]] && echo "virtualenv should be activated before continue" && exit 1
# No usar -z porqué genera un unbound
if ! [[ -v VIRTUAL_ENV ]] ; then
    echo "virtualenv should be activated before continue"
    exit 1
fi

Para entender mejor otras opciones

# Estamos intentando usar una unbound variable, da el mensaje genérico al respecto.
: ${VIRTUAL_ENV} # bash: VIRTUAL_ENV: unbound variable

# Genera su propio mensaje de error, distinto al habitual
: ${VIRTUAL_ENV:?} # bash: VIRTUAL_ENV: parameter null or not set

# definimos la variable pero en blanco
VIRTUAL_ENV=

# Sigue detectando que está vacía
: ${VIRTUAL_ENV:?} # bash: VIRTUAL_ENV: parameter null or not set
: ${VIRTUAL_ENV} # No da error, simplemente es una cadena vacía que no se ejecuta

AGENTS.md and the @ to reference documents

This article is just a quick experiment on:

  • The difference between referencing a document that the agent reads with the @ symbol in front or simply by including the path. @./docs/rule1.md vs simple ./docs/rule1.md.
  • How to split the rules of the LLM into different documents to avoid "burning" context".

The ideas are based on these two articles:

The first test is with gemini-cli, which by default does not use AGENTS.md. In this ticket, there are also people asking if the use of @ is inherent to the model or the tool.

mkdir -p /tmp/experiment/docs
cd /tmp/experiment
echo "Start all your answers with 'Hello fpuga'.

If the user ask any question about Spain read ./docs/foo.md
" > GEMINI.md
echo "Always add a joke at the end of your answers" > docs/foo.md

gemini "What is the capital of France"

>>> Hello fpuga
>>> Paris.

gemini "What is the capital of Spain"

>>> Hello fpuga
>>> Hello fpuga
>>> I need to find the capital of Spain. I will use a web search tool for this.
>>> Hello fpuga
>>> The capital of Spain is Madrid.
>>>
>>> Why did the invisible man turn down the job offer?
>>>
>>> He couldn't see himself doing it!

We see that when we don't use @, the tool/model only loads GEMINI.md, but it's able to load the additional rules when needed.

Let's try it with @.

echo "Start all your answers with 'Hello fpuga'.

If the user ask any question about Spain read @./docs/foo.md
" > GEMINI.md

gemini "What is the capital of France"

>>> Hello fpuga,
>>> The capital of France is Paris.
>>>
>>> Why don't scientists trust atoms? Because they make up everything!

And indeed, when we use @ it follows the references and loads them automatically into the context.

I replicated the experiment with Cursor and Cursor CLI, and in this case, the use of the @ symbol has no effect. It only reads foo.md when asked for Spain. I open a feature request about this.

Conclusions

  • It's a shame there isn't more standardization in how the tools work.
  • If the @ trick were standardized, it would prevent duplication. From AGENTS.md, we could link a simple CONTRIBUTING.md with rules like "Before committing, run ./scripts/format.sh and ./scripts/lint.sh; it must not return any errors." Or "All contributions must maintain test coverage above 75%."
  • The idea of ​​a rules index telling the model when to load additional documents is really good for avoiding context clutter.

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__.py file 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-cli estará 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ómo my-cli
  • Importa el módulo y función que hemos indicado
  • Es multiplataforma. En linux podremos usar my-cli, en windows my-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()
# my-cli/pyproject.toml
[project.scripts]
my-cli = "my_cli.cli:main"

Algunas dudas

Tengo algunas dudas con esta aproximación que modifico según el caso:

  • cli.main o cli.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

# src/my_cli/__main__.py
from my_cli.cli import main

if __name__ == "__main__":
    main()
[project.scripts]
my-tool = "my_cli.__main__:main"

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__.py debería estar reservado para ejecutar con python -m my_tool por Separation of Concerns.
  • El stack trace al usar my-tool irá directamente a cli.py sin pasar con __main__.py que 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.toml podemos 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