Comparación de las bibliotecas de CLI populares para Python: click, cement, fire y otras



Python es un gran lenguaje para aplicaciones de consola, y destaca una gran cantidad de bibliotecas para estas tareas. ¿Pero qué bibliotecas existen? ¿Y cuál es mejor tomar? Este material compara herramientas populares y no muy útiles para el mundo de las consolas y trata de responder la segunda pregunta.

Para facilitar la lectura, la revisión se divide en dos publicaciones: la primera compara las seis bibliotecas más populares, la segunda, la menos popular y más específica, pero aún merece atención.

En cada ejemplo, una utilidad de consola para la biblioteca todolib se escribirá en Python 3.7, con la que puede crear, ver, etiquetar y eliminar tareas. El resto se agregará sujeto a la simplicidad de implementación en un marco particular. Las tareas mismas se almacenan en un archivo json, que se guardará en una llamada separada, una condición adicional para los ejemplos.
Además de esto, se escribirá una prueba trivial para cada implementación. Pytest con los siguientes accesorios se tomó como marco de prueba:

@pytest.fixture(autouse=True) def db(monkeypatch): """ monkeypatch     ,        """ value = {"tasks": []} monkeypatch.setattr(todolib.TodoApp, "get_db", lambda _: value) return value @pytest.yield_fixture(autouse=True) def check(db): """      """ yield assert db["tasks"] and db["tasks"][0]["title"] == "test" # ,      EXPECTED = "Task 'test' created with number 1.\n" 

En principio, todo lo anterior será suficiente para demostrar las bibliotecas. El código fuente completo está disponible en este repositorio.

argparse


Argparse tiene una ventaja innegable: está en la biblioteca estándar y su API no es difícil de aprender: hay un analizador, hay argumentos, los argumentos tienen tipo , acción , dest , predeterminado y ayuda . Y hay un subparser : la capacidad de separar parte de los argumentos y la lógica en comandos separados.

Analizador


A primera vista, nada inusual, el analizador es como un analizador. Pero, en mi opinión, la legibilidad no es la mejor en comparación con otras bibliotecas, porque Los argumentos de los diferentes comandos se describen en un solo lugar.

código fuente
 def get_parser(): parser = argparse.ArgumentParser("Todo notes - argparse version") parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose mode" ) parser.add_argument("--version", "-V", action="store_true", help="Show version") subparsers = parser.add_subparsers(title="Commands", dest="cmd") add = subparsers.add_parser("add", help="Add new task") add.add_argument("title", help="Todo title") show = subparsers.add_parser("show", help="Show tasks") show.add_argument( "--show-done", action="store_true", help="Include done tasks in the output" ) done = subparsers.add_parser("done", help="Mark task as done") done.add_argument("number", type=int, help="Task number") remove = subparsers.add_parser("remove", help="Remove task") remove.add_argument("number", type=int, help="Task number") return parser 


principal


Y aquí lo mismo: el analizador, excepto los argumentos de análisis, no puede hacer nada más, por lo que la lógica tendrá que escribirse de forma independiente y en un solo lugar. Por un lado, es posible vivir, por el otro, es posible mejor, pero aún no está claro cómo.

UPD: Como se señaló foldr , de hecho, los subparsers pueden establecer funciones a través de set_defaults (func = foo), es decir, argparse le permite acortar main a tamaños pequeños. Vive y aprende.

código fuente
 def main(raw_args=None): """ Argparse example entrypoint """ parser = get_parser() args = parser.parse_args(raw_args) logging.basicConfig() if args.verbose: logging.getLogger("todolib").setLevel(logging.INFO) if args.version: print(lib_version) exit(0) cmd = args.cmd if not cmd: parser.print_help() exit(1) with TodoApp.fromenv() as app: if cmd == "add": task = app.add_task(args.title) print(task, "created with number", task.number, end=".\n") elif cmd == "show": app.print_tasks(args.show_done) elif cmd == "done": task = app.task_done(args.number) print(task, "marked as done.") elif cmd == "remove": task = app.remove_task(args.number) print(task, "removed from list.") 


Prueba


Para verificar la salida de la utilidad, se utiliza el dispositivo capsys , que da acceso al texto desde stdout y stderr.

 def test_argparse(capsys): todo_argparse.main(["add", "test"]) out, _ = capsys.readouterr() assert out == EXPECTED 

Resumen


De las ventajas: un buen conjunto de características para el análisis, la presencia de un módulo en la biblioteca estándar.

Contras - argparse solo está involucrado en analizar argumentos, la mayor parte de la lógica en main tuvo que ser escrita por mí mismo. Y no está claro cómo probar el código de salida en las pruebas.

docopt


docopt es un analizador pequeño (<600 líneas, en comparación con 2500 con argparse), que te hará sonreír, citando una descripción en GitHub. La idea principal de docopt es describir la interfaz literalmente con texto, por ejemplo, en docstring.

En el mismo github, docopt> 6700 estrellas, se utiliza en al menos otros 22 mil proyectos. ¡Y esto es solo con la implementación de Python! La página del proyecto docopt tiene muchas opciones para diferentes lenguajes, desde C y PHP hasta CoffeeScript e incluso R. Tal plataforma cruzada solo puede explicarse por la compacidad y simplicidad del código.

Analizador


En comparación con argparse, este analizador es un gran paso adelante.

 """Todo notes on docopt. Usage: todo_docopt [-v | -vv ] add <task> todo_docopt [-v | -vv ] show --show-done todo_docopt [-v | -vv ] done <number> todo_docopt [-v | -vv ] remove <number> todo_docopt -h | --help todo_docopt --version Options: -h --help Show help. -v --verbose Enable verbose mode. """ 

principal


En general, todo es igual que con argparse, pero ahora puede tener varios valores (0-2), y el acceso a los argumentos es diferente: docopt no devuelve un espacio de nombres con atributos, sino solo un diccionario, donde se indica la elección de un comando a través de su booleano, como se ve en si :

código fuente
 def main(argv=None): args = docopt(__doc__, argv=argv, version=lib_version) log.setLevel(levels[args["--verbose"]]) logging.basicConfig() log.debug("Arguments: %s", args) with TodoApp.fromenv() as app: if args["add"]: task = app.add_task(args["<task>"]) print(task, "created with number", task.number, end=".\n") elif args["show"]: app.print_tasks(args["--show-done"]) elif args["done"]: task = app.task_done(args["<number>"]) print(task, "marked as done.") elif args["remove"]: task = app.remove_task(args["<number>"]) print(task, "removed from list.") 


Prueba


Similar a las pruebas de argparse:
 def test_docopt(capsys): todo_docopt.main(["add", "test"]) out, _ = capsys.readouterr() assert out == EXPECTED 

Resumen


De los beneficios: mucho menos código para el analizador, facilidad de descripción y lectura de comandos y argumentos, versión incorporada.

Contras, en primer lugar, lo mismo que argparse: mucha lógica en main , no puede probar el código de salida. Además, la versión actual (0.6.2) de docopt aún no es estable y es poco probable que lo sea: el proyecto se desarrolló activamente desde 2012 hasta finales de 2013, el último compromiso fue el 17 de diciembre. Y lo más desagradable en este momento es que algunos regulares de docopt provocan DeprecationWarning cuando realizan pruebas.

Haga clic en


Click es fundamentalmente diferente de argparse y docopt por la cantidad de características y el enfoque para describir comandos y parámetros a través de decoradores, y se propone que la lógica en sí misma se separe en funciones separadas en lugar de una gran main . Los autores afirman que Click tiene muchas configuraciones, pero los parámetros estándar deberían ser suficientes. Entre las características, se enfatizan los comandos anidados y su carga diferida.

El proyecto es extremadamente popular: además de tener> 8100 estrellas y usarlo en al menos 174 mil (!) Proyectos, todavía se está desarrollando: la versión 7.0 se lanzó en otoño de 2018, y hasta el día de hoy aparecen nuevos compromisos y solicitudes de fusión. día

Analizador


En la página de documentación, encontré el decorador opción_confirmación, que solicita confirmación del usuario antes de ejecutar el comando. Para demostrarlo, se agregó el comando de borrado, que borra toda la lista de tareas.

código fuente
 levels = [logging.WARN, logging.INFO, logging.DEBUG] pass_app = click.make_pass_decorator(TodoApp) @click.group() @click.version_option(lib_version, prog_name="todo_click") @click.option("-v", "--verbose", count=True) # click   ,     @click.option("--db", help="Path to the database file") @click.pass_context def cli(ctx, verbose, db): """Todo notes - click version.""" level = levels[min(verbose, 2)] logging.basicConfig(level=level) logging.getLogger("todolib").setLevel(level) ctx.obj = TodoApp.fromenv(db) atexit.register(ctx.obj.save) @cli.command() @click.argument("task") @pass_app def add(app, task): """ Add new task. """ task = app.add_task(task) click.echo(f"{task} created with number {task.number}.") @cli.command() @click.option("--show-done", is_flag=True, help="Include done tasks") @pass_app def show(app, show_done): """ Show current tasks. """ app.print_tasks(show_done) @cli.command() @click.argument("number", type=int) @pass_app def done(app, number): """ Mark task as done. """ task = app.task_done(number) click.echo(f"{task} marked as done.") @cli.command() @click.argument("number", type=int) @pass_app def remove(app, number): """ Remove task from the list. """ task = app.remove_task(number) click.echo(f"{task} removed from the list.") @cli.command() @click.confirmation_option(prompt="Are you sure you want to remove database") @pass_app def wipe(app): for task in app.list_tasks(): task.remove() 


principal


Y aquí nos encontramos con la principal ventaja de Click: debido al hecho de que la lógica de los comandos está separada según sus funciones, casi nada queda en main. También se demuestra aquí la capacidad de la biblioteca para recibir argumentos y parámetros de las variables de entorno.

 if __name__ == "__main__": cli(auto_envvar_prefix="TODO") 

Prueba


En el caso de Click, no hay necesidad de interceptar sys.stdout, ya que hay un módulo click.testing con un corredor para tales cosas. Y CliRunner no solo intercepta la salida, sino que también le permite verificar el código de salida, que también es genial. Todo esto permite probar las utilidades de clics sin usar pytest y pasar por alto el módulo de prueba de unidad estándar.

 import click.testing def test_click(): runner = click.testing.CliRunner() result = runner.invoke(todo_click.cli, ["add", "test"]) assert result.exit_code == 0 assert result.output == EXPECTED 

Resumen


Esto es solo una pequeña parte de lo que Click puede hacer. Del resto de la API: validación de valores, integración con el terminal (colores, buscapersonas al menos, barra de progreso, etc.), devolución de llamadas de resultados, finalización automática y mucho más. Puedes ver sus ejemplos aquí .

Pros: muchas herramientas para cualquier ocasión, un enfoque original, pero al mismo tiempo conveniente para describir equipos, facilidad de prueba y vida activa del proyecto.

Contras: ¿Cuáles son las desventajas de un "clic"? Esta es una pregunta difícil. ¿Quizás no sabe algo de lo que son capaces las siguientes bibliotecas?

Fuego


Fire no es solo una biblioteca joven (apareció en 2017) para interfaces de consola de Google, es una biblioteca para generar interfaces de consola, citando textualmente, absolutamente cualquier objeto de Python.
Entre otras cosas, se afirma que fire ayuda en el desarrollo y la depuración de código, ayuda a adaptar el código existente en la CLI, facilita la transición de bash a Python y tiene su propia REPL para el trabajo interactivo. Vamos a ver?

Analizador y principal


fire.Fire es realmente capaz de aceptar cualquier objeto: un módulo, una instancia de clase, un diccionario con nombres de comandos y funciones correspondientes, etc.

Lo importante para nosotros es que Fire permite la transferencia de un objeto de clase. Por lo tanto, el constructor de la clase acepta argumentos comunes a todos los comandos, y sus métodos y atributos son comandos separados. Usaremos esto:

código fuente
 class Commands: def __init__(self, db=None, verbose=False): level = logging.INFO if verbose else logging.WARNING logging.basicConfig(level=level) logging.getLogger("todolib").setLevel(level) self._app = todolib.TodoApp.fromenv(db) atexit.register(self._app.save) def version(self): return todolib.__version__ def add(self, task): """Add new task.""" task = self._app.add_task(task) print(task, "created with number", task.number, end=".\n") def show(self, show_done=False): """ Show current tasks. """ self._app.print_tasks(show_done) def done(self, number): """ Mark task as done. """ task = self._app.task_done(number) print(task, "marked as done.") def remove(self, number): """ Removes task from the list. """ task = self._app.remove_task(number) print(task, "removed from the list.") def main(args=None): fire.Fire(Commands, command=args) 


Banderas en línea


Fire tiene sus propias banderas con una sintaxis especial (deben pasarse después del "-"), que le permiten mirar debajo del capó del analizador y la aplicación en su conjunto:

ejemplos de llamadas
 $ ./todo_fire.py show -- --trace Fire trace: 1. Initial component 2. Instantiated class "Commands" (todo_fire.py:9) 3. Accessed property "show" (todo_fire.py:25) $ ./todo_fire.py -- --verbose | head -n 12 #    , ,  Commands._app NAME todo_fire.py - SYNOPSIS todo_fire.py - GROUP | COMMAND GROUPS GROUP is one of the following: _app Todo Application definition. $ ./todo_fire.py show -- --interactive Fire is starting a Python REPL with the following objects: Modules: atexit, fire, logging, todolib Objects: Commands, args, component, main, result, self, todo_fire.py, trace Python 3.7.4 (default, Aug 15 2019, 13:09:37) [GCC 7.4.0] on linux Type "help", "copyright", "credits" or "license" for more information. (InteractiveConsole) >>> self <__main__.Commands object at 0x7fd0a6125bd0> >>> self._app.db {'tasks': [{'title': 'test', 'done': False}]} 


Prueba


Probar la función principal es similar a probar argparse y docopt, por lo que no veo el punto aquí.

Al mismo tiempo, vale la pena señalar que, debido a la naturaleza introspectiva de Fire, es igualmente posible probar la clase Comandos de inmediato.

Resumen


El fuego es una herramienta no menos interesante que el clic. No requiere enumerar muchas opciones en el analizador, la configuración es mínima, hay opciones para la depuración, y la biblioteca misma vive y se desarrolla aún más activamente que el clic (60 confirmaciones este verano).

Contras: puede significativamente menos que el clic y otros analizadores; API inestable (la versión actual es 0.2.1).

Cemento


De hecho, Cement no es exactamente una biblioteca CLI, sino un marco para aplicaciones de consola, pero se afirma que es adecuado para scripts y aplicaciones complejas con diversas integraciones.

Analizador


El analizador en Cement parece inusual, pero si observa de cerca los parámetros, es fácil adivinar que el argumento familiar está bajo el capó. Pero tal vez esto sea lo mejor: no es necesario aprender nuevos parámetros.

código fuente
 from cement import Controller, ex class Base(Controller): class Meta: label = "base" arguments = [ ( ["-v", "--version"], {"action": "version", "version": f"todo_cement v{todolib.__version__}"}, ) ] def _default(self): """Default action if no sub-command is passed.""" self.app.args.print_help() @ex(help="Add new task", arguments=[(["task"], {"help": "Task title"})]) def add(self): title = self.app.pargs.task self.app.log.debug(f"Task title: {title!r}") task = self.app.todoobj.add_task(title) print(task, "created with number", task.number, end=".\n") @ex( help="Show current tasks", arguments=[ (["--show-done"], dict(action="store_true", help="Include done tasks")) ], ) def show(self): self.app.todoobj.print_tasks(self.app.pargs.show_done) @ex(help="Mark task as done", arguments=[(["number"], {"type": int})]) def done(self): task = self.app.todoobj.task_done(self.app.pargs.number) print(task, "marked as done.") @ex(help="Remove task from the list", arguments=[(["number"], {"type": int})]) def remove(self): task = self.app.todoobj.remove_task(self.app.pargs.number) print(task, "removed from the list.") 


Aplicación y principal


El cemento, entre otras cosas, todavía envuelve señales en excepciones. Esto se demuestra aquí en la salida de código cero con SIGINT / SIGTERM.

código fuente
 class TodoApp(App): def __init__(self, argv=None): super().__init__(argv=argv) self.todoobj = None def load_db(self): self.todoobj = todolib.TodoApp.fromenv() def save(self): self.todoobj.save() class Meta: # application label label = "todo_cement" # register handlers handlers = [Base] hooks = [("post_setup", lambda app: app.load_db()), ("pre_close", lambda app: app.save())] # call sys.exit() on close close_on_exit = True def main(): with TodoApp() as app: try: app.run() except CaughtSignal as e: if e.signum not in (signal.SIGINT, signal.SIGTERM): raise app.log.debug(f"\n{e}") app.exit_code = 0 


Si tiene una idea de main, puede ver que cargar y guardar todolib.TodoApp también se puede hacer en el __enter __ / __ exit__ anulado, pero estas fases finalmente se separaron en métodos separados para demostrar los ganchos de cemento.

Prueba


Para las pruebas, puede usar la misma clase de aplicación:

 def test_cement(capsys): with todo_cement.TodoApp(argv=["add", "test"]) as app: app.run() out, _ = capsys.readouterr() assert out == EXPECTED #     jinja,       assert app.last_rendered is None 

Resumen


Pros: El conjunto de API es como un conjunto de cuchillos suizos, extensibilidad a través de ganchos y complementos, una interfaz estable y desarrollo activo.

Contras: en lugares documentación vacía; Los pequeños guiones basados ​​en cemento pueden parecer un poco complicados.

Cleo


Cleo está lejos de ser un marco tan popular como los otros enumerados aquí (alrededor de 400 estrellas en GitHub en total), y sin embargo logré conocerlo cuando estudié cómo Poetry formateó la salida.

Entonces, Cleo es uno de los proyectos del autor del ya mencionado Poesía, una herramienta para administrar dependencias, virtualenvs y compilaciones de aplicaciones. Sobre Poesía en un habr ya más de una vez escribió, y sobre su parte de la consola - no.

Analizador


Cleo, como el cemento, se basa en principios de objeto, es decir los comandos se definen a través de la clase Command y su cadena de documentación, se accede a los parámetros a través del método option (), etc. Además, el método line (), que se utiliza para generar texto, admite estilos (es decir, colores) y filtrado de salida en función del número de marcas detalladas fuera de la caja. Cleo también tiene salida de tabla. Y también barras de progreso. Y sin embargo ... En general, vea:

código fuente
 from cleo import Command as BaseCommand # cleo    clikit,          from clikit.api.io import flags as verbosity class Command(BaseCommand): def __init__(self): super().__init__() self.todoapp = None def handle(self): with todolib.TodoApp.fromenv() as app: self.todoapp = app self.do_handle() def do_handle(self): raise NotImplementedError class AddCommand(Command): """ Add new task. add {task : Task to add} """ def do_handle(self): title = self.argument("task") task = self.todoapp.add_task(title) # will be printed only on "-vvv" self.line(f"Title: {title}", style="comment", verbosity=verbosity.DEBUG) self.line(f"Task <info>{task.title}</> created with number {task.number}.") class ShowCommand(Command): """ Show current tasks. show {--show-done : Include tasks that are done.} """ def do_handle(self): tasks = self.todoapp.list_tasks(self.option("show-done")) if not tasks: self.line("There is no TODOs.", style="info") self.render_table( ["Number", "Title", "Status"], [ [str(task.number), task.title, "" if task.done else "✘"] for task in tasks ], ) class DoneCommand(Command): """ Mark task as done. done {number : Task number} """ def do_handle(self): task = self.todoapp.task_done(int(self.argument("number"))) self.line(f"Task <info>{task.title}</> marked as done.") class RemoveCommand(Command): """ Removes task from the list. remove {number : Task number} """ def do_handle(self): task = self.todoapp.remove_task(int(self.argument("number"))) self.line(f"Task <info>{task.title}</> removed from the list.") 


principal


Todo lo que se necesita es crear un objeto cleo.Application y luego pasar comandos para agregarle comandos. Para no repetir durante la prueba, todo esto se transfirió de main al constructor:

 from cleo import Application as BaseApplication class TodoApp(BaseApplication): def __init__(self): super().__init__(name="ToDo app - cleo version", version=todolib.__version__) self.add_commands(AddCommand(), ShowCommand(), DoneCommand(), RemoveCommand()) def main(args=None): TodoApp().run(args=args) 

Prueba


Para probar comandos en Cleo, existe CommandTester , que, como todos los tíos adultos del marco, intercepta las E / S y el código de salida:

 def test_cleo(): app = todo_cleo.TodoApp() command = app.find("add") tester = cleo.CommandTester(command) tester.execute("test") assert tester.status_code == 0 assert tester.io.fetch_output() == "Task test created with number 0.\n" 

Resumen


Pros: estructura de objetos con sugerencias de tipo, lo que simplifica el desarrollo (ya que muchos IDE y editores tienen un buen soporte para el código OOP y el módulo de escritura); Una buena cantidad de funcionalidad para trabajar no solo con argumentos, sino también con E / S.

Más o menos: su parámetro de verbosidad, que solo es compatible con I / O Cleo / CliKit. Aunque puede escribir un controlador personalizado para el módulo de registro, puede ser difícil mantenerlo junto con el desarrollo de cleo.

Contras: obviamente, una opinión personal, una API joven: el marco carece de otro usuario "grande", a excepción de Poetry, y Cleo se está desarrollando en paralelo con el desarrollo y las necesidades de uno; a veces la documentación está desactualizada (por ejemplo, los niveles de registro ahora no se encuentran en el módulo clikit, sino en clikit.api.io.flags), y en general es deficiente y no refleja la API completa.

Cleo, en comparación con Cement, está más centrado en la CLI, y él es el único que ha pensado en formatear (ocultar el seguimiento de pila predeterminado) de las excepciones en la salida predeterminada. Pero él, nuevamente una opinión personal, pierde ante Cement en su juventud y la estabilidad de la API.

En conclusión


En este punto, todos ya tienen su propia opinión, lo cual es mejor, pero la conclusión debería ser: me gustó Click, porque hay muchas cosas en él y es bastante fácil desarrollar y probar aplicaciones con él. Si intenta escribir código al mínimo, comience con Fire. Su script necesita acceso a Memcached, formateado con jinja y extensibilidad: tome Cement y no se arrepentirá. Tienes un proyecto favorito o quieres probar otra cosa: mira Cleo.

Source: https://habr.com/ru/post/466999/


All Articles