¿Cómo puedo dividir mis comandos de clic, cada uno con un conjunto de subcomandos, en varios archivos?

82

Tengo una aplicación de clic grande que he desarrollado, pero navegar a través de los diferentes comandos / subcomandos se está volviendo difícil. ¿Cómo organizo mis comandos en archivos separados? ¿Es posible organizar comandos y sus subcomandos en clases separadas?

Aquí hay un ejemplo de cómo me gustaría separarlo:

en eso

import click

@click.group()
@click.version_option()
def cli():
    pass #Entry Point

command_cloudflare.py

@cli.group()
@click.pass_context
def cloudflare(ctx):
    pass

@cloudflare.group('zone')
def cloudflare_zone():
    pass

@cloudflare_zone.command('add')
@click.option('--jumpstart', '-j', default=True)
@click.option('--organization', '-o', default='')
@click.argument('url')
@click.pass_obj
@__cf_error_handler
def cloudflare_zone_add(ctx, url, jumpstart, organization):
    pass

@cloudflare.group('record')
def cloudflare_record():
    pass

@cloudflare_record.command('add')
@click.option('--ttl', '-t')
@click.argument('domain')
@click.argument('name')
@click.argument('type')
@click.argument('content')
@click.pass_obj
@__cf_error_handler
def cloudflare_record_add(ctx, domain, name, type, content, ttl):
    pass

@cloudflare_record.command('edit')
@click.option('--ttl', '-t')
@click.argument('domain')
@click.argument('name')
@click.argument('type')
@click.argument('content')
@click.pass_obj
@__cf_error_handler
def cloudflare_record_edit(ctx, domain):
    pass

command_uptimerobot.py

@cli.group()
@click.pass_context
def uptimerobot(ctx):
    pass

@uptimerobot.command('add')
@click.option('--alert', '-a', default=True)
@click.argument('name')
@click.argument('url')
@click.pass_obj
def uptimerobot_add(ctx, name, url, alert):
    pass

@uptimerobot.command('delete')
@click.argument('names', nargs=-1, required=True)
@click.pass_obj
def uptimerobot_delete(ctx, names):
    pass
Brad T
fuente

Respuestas:

91

La desventaja de usar CommandCollectionpara esto es que combina sus comandos y funciona solo con grupos de comandos. La mejor alternativa en mi humilde opinión es utilizar add_commandpara lograr el mismo resultado.

Tengo un proyecto con el siguiente árbol:

cli/
├── __init__.py
├── cli.py
├── group1
│   ├── __init__.py
│   ├── commands.py
└── group2
    ├── __init__.py
    └── commands.py

Cada subcomando tiene su propio módulo, lo que hace que sea increíblemente fácil administrar incluso implementaciones complejas con muchas más clases y archivos auxiliares. En cada módulo, el commands.pyarchivo contiene las @clickanotaciones. Ejemplo group2/commands.py:

import click


@click.command()
def version():
    """Display the current version."""
    click.echo(_read_version())

Si es necesario, puede crear fácilmente más clases en el módulo importy usarlas aquí, dando así a su CLI todo el poder de las clases y módulos de Python.

Mi cli.pyes el punto de entrada para toda la CLI:

import click

from .group1 import commands as group1
from .group2 import commands as group2

@click.group()
def entry_point():
    pass

entry_point.add_command(group1.command_group)
entry_point.add_command(group2.version)

Con esta configuración, es muy fácil separar los comandos por preocupaciones y también crear funcionalidades adicionales a su alrededor que puedan necesitar. Me ha servido muy bien hasta ahora ...

Referencia: http://click.pocoo.org/6/quickstart/#nesting-commands

jdno
fuente
¿Cómo pasar el contexto al subcomando si están en módulos separados?
vishal
2
@vishal, eche un vistazo a esta sección de la documentación: click.pocoo.org/6/commands/#nested-handling-and-contexts Puede pasar el objeto de contexto a cualquier comando usando el decorador @click.pass_context. Alternativamente, también hay algo llamado Acceso de contexto global : click.pocoo.org/6/advanced/#global-context-access .
jdno
6
Compilé un MWE usando las pautas de @jdno. Puede encontrarlo aquí
Dror
¿Cómo puedo planificar todos los comandos de grupo? Quiero decir, todos los comandos en primer nivel.
Mithril
3
@Mithril Utilice un CommandCollection. La respuesta de Oscar tiene un ejemplo, y hay uno realmente bueno en la documentación de click: click.palletsprojects.com/en/7.x/commands/… .
jdno
34

Suponga que su proyecto tiene la siguiente estructura:

project/
├── __init__.py
├── init.py
└── commands
    ├── __init__.py
    └── cloudflare.py

Los grupos no son más que varios comandos y los grupos se pueden anidar. Puede separar sus grupos en módulos e importarlos en su init.pyarchivo y agregarlos al cligrupo usando el comando add_command.

He aquí un init.pyejemplo:

import click
from .commands.cloudflare import cloudflare


@click.group()
def cli():
    pass


cli.add_command(cloudflare)

Tienes que importar el grupo cloudflare que vive dentro del archivo cloudflare.py. Tu commands/cloudflare.pyse vería así:

import click


@click.group()
def cloudflare():
    pass


@cloudflare.command()
def zone():
    click.echo('This is the zone subcommand of the cloudflare command')

Entonces puedes ejecutar el comando cloudflare así:

$ python init.py cloudflare zone

Esta información no es muy explícita en la documentación pero si miras el código fuente, que está muy bien comentado, puedes ver cómo se pueden anidar los grupos.

Diego Castro
fuente
5
De acuerdo. Tan mínimo que debería formar parte de la documentación. ¡Exactamente lo que estaba buscando para construir herramientas complejas! ¡Gracias 🙏!
Simon Kemper
Seguro que es genial, pero tengo una pregunta: teniendo en cuenta su ejemplo, ¿debería eliminar @cloudflare.command()de la zonefunción si importo zonedesde otro lugar?
Erdin Eray
Esta es una excelente información que estaba buscando. Otro buen ejemplo sobre cómo distinguir entre grupos de comandos se puede encontrar aquí: github.com/dagster-io/dagster/tree/master/python_modules/…
Thomas Klinger
10

Estoy buscando algo como esto en este momento, en tu caso es simple porque tienes grupos en cada uno de los archivos, puedes solucionar este problema como se explica en la documentación :

En el init.pyarchivo:

import click

from command_cloudflare import cloudflare
from command_uptimerobot import uptimerobot

cli = click.CommandCollection(sources=[cloudflare, uptimerobot])

if __name__ == '__main__':
    cli()

La mejor parte de esta solución es que es totalmente compatible con pep8 y otros linters porque no necesita importar algo que no usaría y no necesita importar * desde ningún lugar.

Oscar David Arbeláez
fuente
¿Puede decirme qué poner en los archivos de subcomando? Tengo que importar main clidesde init.py, pero esto conduce a importaciones circulares. ¿Podría explicar cómo se hace?
Grundic
@grundic Mira mi respuesta si aún no has encontrado una solución. Podría ponerlo en el camino correcto.
jdno
1
@grundic Espero que ya se haya dado cuenta, pero en sus archivos de subcomando simplemente crea uno nuevo click.groupque es el que importa en la CLI de nivel superior.
Oscar David Arbeláez
5

Me tomó un tiempo resolver esto, pero pensé que lo pondría aquí para recordarme a mí mismo cuando me olvide de cómo hacerlo. Creo que parte del problema es que la función add_command se menciona en la página de github de click pero no en la principal. página de ejemplos

primero vamos a crear un archivo python inicial llamado root.py

import click
from cli_compile import cli_compile
from cli_tools import cli_tools

@click.group()
def main():
    """Demo"""

if __name__ == '__main__':
    main.add_command(cli_tools)
    main.add_command(cli_compile)
    main()

A continuación, coloquemos algunos comandos de herramientas en un archivo llamado cli_tools.py

import click

# Command Group
@click.group(name='tools')
def cli_tools():
    """Tool related commands"""
    pass

@cli_tools.command(name='install', help='test install')
@click.option('--test1', default='1', help='test option')
def install_cmd(test1):
    click.echo('Hello world')

@cli_tools.command(name='search', help='test search')
@click.option('--test1', default='1', help='test option')
def search_cmd(test1):
    click.echo('Hello world')

if __name__ == '__main__':
    cli_tools()

A continuación, coloquemos algunos comandos de compilación en un archivo llamado cli_compile.py

import click

@click.group(name='compile')
def cli_compile():
    """Commands related to compiling"""
    pass

@cli_compile.command(name='install2', help='test install')
def install2_cmd():
    click.echo('Hello world')

@cli_compile.command(name='search2', help='test search')
def search2_cmd():
    click.echo('Hello world')

if __name__ == '__main__':
    cli_compile()

ejecutar root.py ahora debería darnos

Usage: root.py [OPTIONS] COMMAND [ARGS]...

  Demo

Options:
  --help  Show this message and exit.

Commands:
  compile  Commands related to compiling
  tools    Tool related commands

ejecutar "root.py compile" debería darnos

Usage: root.py compile [OPTIONS] COMMAND [ARGS]...

  Commands related to compiling

Options:
  --help  Show this message and exit.

Commands:
  install2  test install
  search2   test search

También notará que puede ejecutar cli_tools.py o cli_compile.py directamente y también incluí una declaración principal allí

pan de ajo
fuente
0

No soy un experto en clics, pero debería funcionar simplemente importando sus archivos al principal. Movería todos los comandos en archivos separados y tendría un archivo principal importando los otros. De esa forma es más fácil controlar el orden exacto, en caso de que sea importante para ti. Entonces, su archivo principal se vería así:

import commands_main
import commands_cloudflare
import commands_uptimerobot
Achim
fuente
0

editar: me acabo de dar cuenta de que mi respuesta / comentario es poco más que una repetición de lo que ofrecen los documentos oficiales de Click en la sección "Comandos múltiples personalizados": https://click.palletsprojects.com/en/7.x/commands/#custom -multi-comandos

Solo para agregar a la excelente respuesta aceptada por @jdno, se me ocurrió una función auxiliar que auto-importa y agrega automáticamente módulos de subcomando, lo que reduce enormemente el texto estándar en mi cli.py:

La estructura de mi proyecto es esta:

projectroot/
    __init__.py
    console/
    │
    ├── cli.py
    └── subcommands
       ├── bar.py
       ├── foo.py
       └── hello.py

Cada archivo de subcomando se parece a esto:

import click

@click.command()
def foo():
    """foo this is for foos!"""
    click.secho("FOO", fg="red", bg="white")

(por ahora, solo tengo un subcomando por archivo)

En cli.py, he escrito una add_subcommand()función que recorre cada ruta de archivo agrupada por "subcomandos / *. Py" y luego realiza el comando de importación y adición.

Esto es lo que se simplifica el cuerpo del script cli.py:

import click
import importlib
from pathlib import Path
import re

@click.group()
def entry_point():
    """whats up, this is the main function"""
    pass

def main():
    add_subcommands()
    entry_point()

if __name__ == '__main__':
    main()

Y así es add_subcommands()como se ve la función:


SUBCOMMAND_DIR = Path("projectroot/console/subcommands")

def add_subcommands(maincommand=entry_point):
    for modpath in SUBCOMMAND_DIR.glob('*.py'):
        modname = re.sub(f'/', '.',  str(modpath)).rpartition('.py')[0]
        mod = importlib.import_module(modname)
        # filter out any things that aren't a click Command
        for attr in dir(mod):
            foo = getattr(mod, attr)
            if callable(foo) and type(foo) is click.core.Command:
                maincommand.add_command(foo)

No sé qué tan robusto es esto si tuviera que diseñar un comando que tuviera varios niveles de anidación y cambio de contexto. Pero parece funcionar bien por ahora :)

Dan Nguyen
fuente