Saltar al contenido

Tech Blog | Utilidades Python CLI con poesía y mecanografía

Así que tienes una idea para construir algo… ¡Impresionante! – pero tal vez ese código no tiene sentido para construir algo servible, como una aplicación web. En cambio, quieres hacer una utilidad compartida, para que otros usuarios puedan ejecutar tu herramienta sobre la marcha. Construir tu código en un paquete Python apropiado es genial, pero puede que no necesites realmente que otros usuarios integren tu código, a nivel granular, en el suyo propio – más bien, puede que sólo necesiten ser capaces de activar las herramientas de una manera ad-hoc con relativamente pocas opciones.en ese punto, estás hablando realmente de construir una utilidad ejecutable , en lugar de un paquete.

Desafortunadamente, Python no tiene una historia fantástica para construir ejecutables independientes (aunque herramientas como PyInstaller han recorrido un largo camino) debido a la forma en que el entorno de Python maneja las dependencias. No hay realmente una forma estandarizada de agrupar el código de los ejecutables, sus dependencias y (potencialmente) la información de tiempo de ejecución en un solo bloque distribuible de la manera que, por ejemplo, lo haría un archivo .jar Java gordo o un ejecutable Go.

Tech Blog | Utilidades Python CLI con poesía y mecanografía
Tech Blog | Utilidades Python CLI con poesía y mecanografía

Sin embargo, hay un término medio! Python tiene una buena manera de empaquetar scripts junto con el código asociado en sus paquetes para crear utilidades de línea de comandos (relativamente) cerradas. Aunque esto todavía tiene esas dependencias del entorno (es decir, se instala en un entorno Python particular y necesita dependencias externas instaladas allí), tales herramientas son instalables en pip y, desde el punto de vista de un usuario, pueden ser tratadas como independientes. Este patrón es extremadamente frecuente en las utilidades Python comunes, desde las herramientas de gestión de núcleos como pip y virtualenv hasta las herramientas de calidad de código como black, flake8 y mypy.

A pesar de parecer desalentador, construir este tipo de utilidad de línea de comandos es en realidad bastante fácil en Python! En este post, discutiremos cómo construir una utilidad CLI con algunas nuevas y emocionantes herramientas de Python, incluyendo:

  • construyendo un paquete con poesía: esto nos da un número de beneficios, como herramientas integradas a través del control del ambiente y de la construcción, y una resolución de dependencia determinista completamente especificada
  • escribiendo un guión de línea de comandos: usaremos la nueva librería de mecanografía por su diseño limpio y el uso inteligente de las anotaciones de tipo Python
  • integrando el CLI: horneando el script en los puntos de entrada del paquete para hacer una utilidad instalable

El código completo para este post, con configuración adicional, herramientas de construcción y funcionalidad, se puede encontrar aquí. Cosas que no vamos a cubrir:

  • diseño de pruebas unitarias (aunque el código de ejemplo vinculado incluye pruebas de paquetes)
  • CI o despliegue, ya que esto es muy específico de la organización – sin embargo, la poesía juega bien con cualquier índice de paquete Python estándar (es decir, PyPI público, o un almacén privado de artefactos)

¡En marcha!

construyendo un paquete

Primero, consideremos una idea de lo que hará nuestra herramienta. He estado jugando mucho a Calabozos y Dragones últimamente (y tenemos dados de lujo para escribir entradas de blog), así que una aplicación para lanzar dados suena bien – ¡hagamos algo para eso!

En primer lugar, queremos la capacidad de especificar un número de dados a tirar, y su tamaño (es decir, el número de lados) – vamos a tener que devolver una lista de las tiradas individuales (ordenadas en orden descendente en caso de que queramos escoger tiradas más grandes) y su total, escribiendo “2D6” para especificar que se tiren dos dados de seis caras – así que queremos una función que pueda analizar una cadena como esa en las entradas numéricas para nuestra primera función. Para empezar, podemos escribir estas funciones en un archivo… llamémoslo dados.py:

importar de la importación de los tipos de letraTuple,Listimportrandomdefroll(num_dice:int=1,sides:int=20)-;Tuple[List[int],int]:rolls=sorted([random. choice(range(1,sides+1))for_inrange(num_dice)],reverse=True)return(rolls,sum(rolls))defparse_dice_string(dice_string:str)- >Tuple[int,int]:# extrae los dígitos de las cadenas de los dados como "2D6" con regex witchcrafthit=re. search(r"(d*)[dD](d+)",dice_string)ifnothit:raiseValueError("bad string")count,sides=hit. groups()count_int=int(countor1)# regex golpea en "" para el 1er dígito, munge a 1sides_int=int(sides)return(count_int,sides_int)defroll_from_string(dice_string: str)-;Tuple[List[int],int,str]:count,sides=parse_dice_string(dice_string)rolls,total=roll(num_dice=count,sides=sides)return(rolls,total,f"{count}D{sides}")

(De momento nos saltaremos el escribir cadenas de documentos para estos, y nos basaremos en los nombres de variables útiles y en los indicios de tipo Python para guiar a la gente que lea nuestro código – el código de ejemplo está más documentado).

A continuación, vamos a tratar de construir esto en un paquete – vamos a utilizar la herramienta de poesía, ya que nos da bastantes detalles, como herramientas integradas para el control del medio ambiente y la construcción, y la resolución de la dependencia determinista:

(después de añadir nuestro dice.py y escribir algunas pruebas de unidad apropiadas, por supuesto). Anidando código según sea necesario en directorios (cada uno con un archivo __init__.py), indicamos que la estructura de directorios debe ser interpretada como una ruta de módulo – es decir, podemos importar nuestras funciones desde el módulo roll_the_dice.dice.Todo lo que necesitamos para gestionar el entorno y la construcción de nuestro proyecto está en pyproject.toml:

[tool.poetry]name="roll-the-dice "version="0.0.1 "description="a roll the dice CLI "authors=[ "John Walk <[email protected]>"][tool.poetry. dependencias]python="^3.7 "typer="^0.0.8"[tool.poetry.dev-dependencies]pytest="^5.2"[build-system]requires=["poetry >=0.12"]build-backend="poetry.masonry.api"

(Más opciones de configuración para la poesía se puede encontrar aquí.) Con esta configuración, simplemente ejecutando poetry install se creará un entorno virtual con nuestras dependencias especificadas instaladas, listo para trabajar con el código del paquete. Ejecutando poetry shell se lanzará un shell en ese entorno virtual para pruebas interactivas, y podemos ejecutar comandos de shell en el entorno con poetry run (e.g., poetry run pytest ejecutará nuestras pruebas unitarias en la raíz del proyecto, descubriendo automáticamente nuestros archivos de prueba en la estructura del proyecto).En última instancia, podemos usar poetry build -f wheel y poetry publish para ensamblar nuestro paquete en un archivo wheel y empujarlo a PyPI (o cualquier otro índice de paquete que deseemos).

escribiendo un guión de línea de comandos

En esencia, escribir un simple script de línea de comandos es sencillo en Python – para obtener un comportamiento equivalente a ejecutar comandos individuales en un shell de Python, sólo necesitamos un archivo (llamémoslo cli.py) estructurado de esta manera:

defdo_algo():# funciones que hacen algunas cosas.....si__nombre__=="__main__":do_something()

para lo cual ejecutando python cli.py desde la línea de comandos se ejecutará cualquier comando especificado en el bloque if final (es decir, do_something() en este caso).esto parece un poco arcano, pero en general no tendremos que preocuparnos por ello – la explicación corta es que Python establece automáticamente un atributo __name__ a nivel de módulo para cualquier código (por ejemplo, import foo establecerá __name__ como “foo” para el código que contiene), con “__main__” como nombre reservado para la ejecución de nivel superior desde la línea de comandos.Basta con decir que ese bloque se ejecuta cuando se le llama directamente desde la línea de comandos, y se ignora en todas las demás instancias (como si un usuario importara cli.py), por lo que nos da un gancho conveniente para nuestros scripts.

En la biblioteca estándar, Python proporciona un paquete llamado argparse que puede construir argumentos de línea de comandos para una función, pero requiere una estructura algo opaca (pero muy flexible) para interconectar entre los argumentos de línea de comandos y los argumentos pasados a las funciones de Python internamente. La nueva librería de tecleado (construida sobre el también excelente clic) hace que esto sea mucho más sencillo, al hornear los argumentos de línea de comandos directamente en las llamadas a funciones Python utilizando el nuevo sistema de anotación de tipos de Python. Typer está construido por el mismo diseñador que FastAPI (otro de mis favoritos), y aprovecha muchas de las mismas decisiones de diseño para lograr esta inteligencia.

Primero, veamos la creación de un guión muy simple:

"Importtyperdefhello_world():"""nuestro primer CLI con typer! """typer.echo("Opening blog post…")typer.launch("https://pluralsight.com/tech-blog/python-cli-utilities-with-poetry-and-typer")if__name__=="__main__":typer.run(hello_world)</pre>

Para comandos sencillos, basta con llamar a typer.run en una función – llamarla desde la línea de comandos como python cli.py activará el script, imprimiendo en la línea de comandos y lanzando esta entrada de blog en el navegador.la llamada de typer añade una serie de detalles también, como el resaltado de sintaxis & colorear en la terminal a través de typer. echo. Incluso comienza a construir documentación y opciones de llamada – ejecutando python cli.py –help mostrará un mensaje de ayuda con el contenido de la cadena de documentación de la función y cualquier argumento u opción (incluyendo la bandera autogenerada –help).

Podemos añadir aún más control instanciando una aplicación de mecanografía en su lugar – en lugar de llamar directamente a run, podríamos escribir lo anterior como

…importtyperapp=typer.Typer()@app.command("hola")defhello_world()…# contenido de la función aboveif__name__=="__main__":app()</pre>

Si estás familiarizado con Flask o FastAPI (del mismo autor que el mecanógrafo) para crear aplicaciones web, este patrón debería parecerte bastante familiar, pero en lugar de crear una aplicación web, estás creando puntos finales para una aplicación de línea de comandos . Esto nos permite crear múltiples subcomandos para el script de forma trivial, simplemente vinculando múltiples funciones a la aplicación (como los endpoints en una aplicación web), y nos permite añadir algunas características adicionales como especificar el nombre del comando (en este caso, python cli.py hola) para anular el nombre de la función para llamadas más limpias.

A continuación, escribiremos nuestro primer comando útil (es decir, uno que utilice nuestras herramientas de roll_the_dice) – para empezar, escribiremos un comando que lance los dados desde una cadena de entrada, lo que significa que tendremos que ser capaces de manejar inteligentemente las entradas de la línea de comandos en nuestro script. En una herramienta como argparse, esto requeriría crear un objeto separado sólo para manejar los argumentos, llamándolos de una manera un tanto indirecta.con typer, sin embargo, sólo tenemos que añadir argumentos a las propias funciones con anotaciones de tipo, y la aplicación CLI se encarga del resto (de nuevo, este patrón será familiar para cualquiera que haya hecho el manejo de parámetros de petición en FastAPI).

@app.command("roll-str")defroll_string(dice_str:str):""";;Roda los dados a partir de una cadena formateada. Suministramos una cadena formateada DICE_STR que describe el lanzamiento, por ejemplo '2D6' para dos dados de seis caras. """try:rolls_list,total,formatted_roll=roll_from_string(dice_str)exceptValueError:typer. echo(f"cadena de rollos inválida: {dice_str}")raisetyper.Exit(code=1)typer.echo(f"rolling {formatted_roll}!
")typer.echo(f"tu rollo: {total}
…y no..;

La aplicación de mecanografía toma automáticamente esta función argumento & escribe la anotación, y la construye en un argumento posicional para la llamada al guión – podemos llamarlo como

python cli.py roll-str 2D6</pre

y typer maneja correctamente el argumento de entrada (nótese que accedemos al comando con el nombre especificado en app.command).la aplicación maneja automáticamente los argumentos faltantes o extra en la llamada, y nos da una forma fácil de enganchar los errores de Python (como el ValueError levantado por las cadenas mal formateadas) en los errores de CLI.incluso genera una cadena de ayuda útil para el comando usando las anotaciones más la docstring de la función:

$ python cli.py roll-str --helpUsage: cli.py roll-str [OPCIONES] DICE_STR Tira los dados desde una cadena formateada. Suministramos una cadena formateada DICE_STR que describe la tirada, por ejemplo, $00272D6$0027 para dos dados de seis caras. Opciones: --ayuda a mostrar este mensaje y salir.

También podemos ser más elegantes con nuestras opciones de línea de comandos. Mientras que el mecanógrafo interpretará los argumentos anotados de las palabras clave como opciones o banderas (de la misma manera que los argumentos posicionales de la función Python se tratan como arcos requeridos para la CLI), también proporciona funciones de ayuda para un control aún mayor. Podemos utilizar los comandos mecanógrafo.Argumento y mecanógrafo.Opción para entradas de palabras clave y posicionales, permitiéndonos establecer cosas como cadenas de ayuda, anulaciones para nombres de banderas y validación básica de entradas.

Utilizaremos banderas de opción para las entradas de un comando que tira dados directamente desde entradas numéricas (es decir, pasamos explícitamente el número y el tamaño de los dados a tirar), de tal forma que podríamos elegir omitir opciones en favor de utilizar los valores predeterminados.es decir, queremos llamar a la función así para tirar un par de D20:

$ python cli.py roll-num -n 2 -d 20 --rolls

(o podemos saltarnos cualquiera de las dos entradas para utilizar el valor por defecto definido en la función). La aplicación de mecanografía interpreta correctamente los argumentos de las palabras clave para estas opciones:

@app.command("roll-num")defroll_num(num_dice:int=typer.Option(1,"-n","-num-dice",help="number of dice to roll",show_default=True,min=1),sides:int=typer. Option(20,"-d","--caras",help="número de caras de los dados a tirar",show_default=True,min=1),rolls:bool=typer.Option(False,help="set to display individual rolls",show_default=True),):"""Tira los dados desde entradas numéricas. Suministramos el número y el recuento lateral de dados a tirar con argumentos de opción. """rolls_list,total=roll(num_dice=num_dice,sides=sides)typer.echo(f "rolling {num_dice}D{sides}!
")typer.echo(f "tu rollo: {total}
")ifrolls:typer.echo(f "compuesto por {rolls_list}
")

Las banderas de opción nos permiten especificar los nombres de las banderas (incluidas las versiones de formato corto y largo, como -d para –lados), establecer una cadena de ayuda para las banderas e incluso hacer cumplir los valores mínimos o máximos de las entradas:

$ python cli.py roll-num --helpUsage: cli.py roll-num [OPCIONES] Lanza los dados desde entradas numéricas. Suministramos el número y el recuento lateral de dados a tirar con argumentos de opción. Opciones: -n, --num-dice INTEGER RANGE número de dados a tirar [por defecto: 1] -d, --los lados INTEGER RANGE número de lados de los dados a tirar [por defecto: 20] --rodillos / --no-rodillos configurados para mostrar los rollos individuales [por defecto: False] --ayuda a mostrar este mensaje y salir.

Typer incluso añade algo de inteligencia para las banderas booleanas, generando automáticamente opciones de bandera y sin bandera en lugar de requerir que el usuario pase valores de verdadero/falso. (Se pueden encontrar otras técnicas inteligentes de manejo de parámetros para cosas como fechas, rutas de archivo y opciones enumeradas para los comandos). Para los scripts multi-comando como este, Typer también autogenerará ayuda de alto nivel:

$ python cli.py --helpUsage: cli.py [OPCIONES] COMANDO [ARGOS]...Opciones: --help Show this message and exit.Commands: hello our first CLI with typer! roll-num Lanza los dados desde entradas numéricas. roll-str Lanza los dados desde una cadena formateada.

(nótese que las descripciones de los comandos tienen un poco de ingenio – utiliza automáticamente la primera frase de la cadena de documentación, por lo que debería ser una descripción “de una línea” del comando).

En conjunto, esto nos da una gran manera de manejar los scripts de la línea de comandos.Podemos crear fácilmente múltiples subcomandos según sea necesario, cada uno con documentación automática y cualquier validación de datos que necesitemos, con un mínimo de código adicional sobre lo que necesitaríamos para la función Python desnuda.A continuación, veamos cómo podemos integrar esto en nuestro paquete en una herramienta independiente, en lugar de sólo un script.

integrando el CLI

El script anterior es perfectamente funcional para la línea de comandos, es decir, podemos llamarlo con python cli.py y funcionará (siempre que tengamos instalado nuestro paquete roll-the-dice). Sin embargo, esto no es realmente ideal para construir una utilidad autónoma verdaderamente reutilizable. Por un lado, no tenemos realmente una buena manera de distribuir el script – los usuarios pueden instalar el roll-the-dice (una vez que lo hemos empujado a un índice de paquetes), pero tendrían que administrarlo por separado (¡y el control de versiones! ) del script, sino que queremos una forma de incluir el script con el propio paquete, de manera que el script se instale y controle la versión junto con el paquete como un comando autónomo (muy parecido a la forma en que herramientas de Python como flake8 o mypy pueden ser importadas o llamadas desde la línea de comandos), es decir, en lugar de nuestras incómodas llamadas a python cli.py, queremos convertir nuestro script en un comando (llamémoslo rtd) que podamos llamar como

$ rtd COMANDO [OPCIONES] ARGS

Históricamente, las utilidades de empaquetado de Python han soportado una configuración de scripts, donde podíamos incluir archivos de script como el que escribimos arriba en un directorio bin/ de nivel superior (paralelo a los directorios de fuente del paquete y de prueba).el proceso de construcción de setuptools empaquetaría estos archivos con el archivo fuente del paquete o el archivo wheel, y copiaría los scripts al directorio bin/ del entorno Python en la instalación para crear un comando accesible cuando el entorno está activo.sin embargo, esto se topa con cierta incomodidad a la hora de integrar el código de script con el resto del paquete (por ejemplo, para probar, o manejar código fuente de múltiples archivos para herramientas complejas) y es difícil de hacer funcionar tanto en sistemas Windows como POSIX.

En su lugar, usaremos el más moderno punto de entrada console_scripts para crear nuestro comando. Esto nos permite incluir las herramientas de línea de comandos directamente en la función, y evita cualquier confusión con hackeos de espacios de nombres (es decir.., En la instalación del paquete, Python creará automáticamente scripts en el directorio bin/de su entorno que simplemente importan las funciones referenciadas y se ejecutan desde allí (en lugar de copiar todo el código fuente referenciado por la palabra clave de los antiguos scripts).

Comencemos. Primero, simplemente copiamos nuestro archivo cli.py en el paquete, donde lo trataremos como cualquier otro submódulo:

<...rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco, rodar el disco...

En nuestro archivo CLI, necesitamos reemplazar la invocación __main__ con una función ordinaria: en lugar de

if__name__=="__main__":app()

simplemente necesitamos

defmain():app()

Dado que este es sólo otro submódulo de nuestro paquete, podemos incluso escribir pruebas unitarias para él como cualquier otra función – pytest proporciona un accesorio de capsys incorporado para capturar la salida estándar y los registros de errores para que podamos probar fácilmente en las salidas de los comandos CLI, como a continuación:

deftest_roll_num(capsys):roller=roll_num(num_dice=1,sides=20)stdout=capsys.readouterr().outregex=re.compile(r "rolling (d+Dd+)!
tu rollo: (d+)")roll_str,total=re.search(regex,stdout).groups()assertroll_str=="1D20 "assertint(total)inrange(1,21)

(Obsérvese, sin embargo, que en los casos en que se utilice typer.Argument o typer.Option es probable que necesitemos proporcionar explícitamente valores, ya que de lo contrario el intérprete de Python no analizará correctamente los campos de mecanografía a sus valores subyacentes). Por último, necesitamos definir esta función como el punto de entrada en nuestro archivo pyproject.toml:

[herramienta.poesía.scripts]rtd="roll_the_dice.cli:main"

(en realidad podríamos hacer referencia directa a la función de la aplicación aquí, haciendo innecesario main() – pero este patrón es más explícito, permite cualquier llamada de configuración adicional necesaria alrededor de la instanciación de la aplicación, y minimiza los objetos importados en el script autogenerado). Podemos acceder a este comando durante el desarrollo con poetry run rtd, ya que poetry ejecutará cualquier script definido en el archivo TOML dentro de su entorno virtual incluido.

En la construcción del paquete, la poesía lo convertirá automáticamente en un punto de entrada estándar del paquete, y al instalar ese punto de entrada creará un comando CLI rtd en nuestro entorno Python que podemos llamar perfectamente normal.Con eso hecho, estamos listos para tirar nuestros dados, por ejemplo, rtd roll-str 2D6 para llegar a nuestra primera función de rodaje – tenemos una herramienta CLI completamente funcional a nuestra disposición!

envolviendo

Aunque Python no tiene una historia fantástica para construir ejecutables completamente autónomos, es sin embargo un gran lenguaje para la creación de scripts, y las herramientas modernas hacen fácil empaquetar nuestros scripts en utilidades de línea de comandos instalables en pip, permitiéndonos validar, probar y controlar las versiones de los scripts:

  • construyó una herramienta rápida de desplazamiento de dados en un paquete Python con poesía
  • diseñó un guión de línea de comandos usando ese paquete con la nueva biblioteca de mecanografía
  • integró ese guión en una herramienta de línea de comandos usando los puntos de entrada del paquete

El uso de estas herramientas de última generación nos da muchos beneficios, como la integración de herramientas y la resolución de dependencias con la poesía o el diseño limpio de la CLI con mecanografía, pero si quisiéramos podríamos usar fácilmente otras herramientas para este diseño. Por ejemplo, el punto de entrada console_scripts es agnóstico del comportamiento real del CLI – sólo necesita acceso a una función importable que no toma argumentos (desde dentro de Python – en su lugar los argumentos son manejados por nuestro analizador CLI de elección). Si estuviéramos usando argparse, por ejemplo, sólo necesitaríamos incluir el manejo para el objeto ArgumentParser en nuestra función main().

De manera similar, podríamos construir nuestro paquete (incluyendo los scripts) con setuptools en lugar de poesía – esto nos hace perder el control del entorno integrado, del paquete y de la construcción, pero en algunos casos será necesario (por ejemplo, construyendo un paquete con enlaces a código compilado no Python). Todavía podemos obtener muchos beneficios de una configuración de construcción más moderna apoyándonos en un archivo setup.cfg en lugar de apilar la configuración en el archivo ejecutable setup.py. Para incluir nuestro script como punto de entrada para un paquete con setuptools, sólo tenemos que añadirlo a nuestro archivo setup.cfg:

[options.entry_points]console_scripts= rtd = roll_the_dice.cli:main

que construirá el punto de entrada del paquete console_scripts idéntico a la opción tool.poetry.scripts con poesía.Cualquiera que sea la herramienta que elijamos, la construcción de herramientas CLI instalables con Python es una gran manera de distribuir scripts ad-hoc de una manera probada y controlada por versiones con poca sobrecarga adicional.

Categorías: technicalTags: python, getting started