Haciendo comandos con Click!

roberto.majadas

¿Qué es un Click?

Command Line Interface Creation Kit

¿Por qué Click?

  • Toltalmente anidable y componible
  • Generación de páginas de ayuda
  • "Lazy loading" de comandos en tiempo de ejecución
  • Soporte para las convenciones de UNIX
  • Carga de variables de entorno de serie
  • Soporte para Python 2 y 3
  • Gestión de descriptores de ficheros de serie
  • Muchos, muchos helpers

¿Y por qué no Argparse?

  • Click está basado en optparse
  • Hace demasiada "magia" para saber que es un argumento o un parámetro
  • Dificulta el anidamiento de comandos

¿Y por qué no Docopt?

  • Su arquitectura persigue objetivos diferentes
    • Dependiente de la ayuda que hagas
    • Es difícil hacer composición de comandos
    • Tiene soporte de subcomandos, pero...
  • Y click...
    • No solo parsea, asigna el código apropiado
    • Fuerte contexto de invocación (subcomando -> comando)
    • Mucha info de todo

¿Y por qué no Docopt?

Click está orientado a tener un sistema componible y Docopt a tener el CLI más bonito hecho a mano.

Instalar Click

            
virtualenv .venv
source .venv/bin/activate
pip install click
            
          

Hello world!

            
#!/usr/bin/env python

import click


@click.command()
def hello():
    click.echo('Hello World!')


if __name__ == '__main__':
    hello()
            
          
            
./hello_world.py
Hello World!
            
          

Anidando comandos

            
#!/usr/bin/env python
import click

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

@cli.command()
def say_hello():
    click.echo('Hello hacker!')

@cli.command()
def say_bye():
    click.echo('Bye hacker!')


if __name__ == '__main__':
    cli()

            
          

Anidando comandos (resultado)

            
$ anidando
Usage: anidando [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  say_bye
  say_hello
            
          

Añadiendo parámetros

            
#!/usr/bin/env python
import click

@click.command()
@click.option('--count-down', default=10, help='suprise countdown')
@click.argument('name')
def surprise(count_down, name):
    while count_down != 0:
        click.echo("%s..." % count_down)
        count_down = count_down - 1

    click.echo("Supriseeeeee!!!! %s" % name)

if __name__ == '__main__':
    surprise()

            
          

Añadiendo parámetros, resultado

            
$ surprise --help
Usage: surprise [OPTIONS] NAME

Options:
  --count-down INTEGER  suprise countdown
  --help                Show this message and exit.

$ surprise --count-down 3 roberto
3...
2...
1...
Supriseeeeee!!!! roberto

            
          

Integración con setuptools

            
from setuptools import setup

setup(
    name='yourscript',
    version='0.1',
    py_modules=['yourscript'],
    install_requires=[
        'Click',
    ],
    entry_points='''
        [console_scripts]
        yourscript=yourscript:cli
    ''',
)
            
          

Parámetros

Click soporta dos clases de parámetros: options y arguments

  • Arguments
    • mi_comando NOMBRE
    • Usar para nombres, ficheros, etc.
  • Options
    • mi_comando --help
    • Totalmente documentados
    • Si falta el dato, te lo pide
    • Precarga por variables de entorno

Parámetros

Tipos de datos

  • str / click.STRING
  • int / click.INT
  • float / click.FLOAT
  • bool / click.BOOL
  • click.UUID, click.File, click.Path, click.Choice, click.IntRange
  • Tipos custom, muy faciles de implementar!

Jugando con Options

Una opción básica

          
@click.command()
@click.option('--n', default=1)
def dots(n):
    click.echo('.' * n)
          
        
  • default, define el valor por defecto
  • Si no hay tipo de dato definido por defecto es String

Jugando con Options

opciones multi-valor

          
@click.command()
@click.option('--pos', nargs=2, type=float)
def findme(pos):
    click.echo('%s / %s' % pos)
          
        
  • nargs, define numero de argumentos
  • type, define el tipo de dato que acepta

Jugando con Options

Usando tuplas para opción multi-valor

          
@click.command()
@click.option('--item', type=(unicode, int))
def putitem(item):
    click.echo('name=%s id=%d' % item)
          
        
  • 2 entradas, un string unicode y un entero

Jugando con Options

Multiples entradas de la misma opción

          
@click.command()
@click.option('--message', '-m', multiple=True)
def commit(message):
    click.echo('\n'.join(message))
          
        
          
$ commit -m foo -m bar
foo
bar
          
        

Jugando con Options

Contador asociado a una opción

          
@click.command()
@click.option('-v', '--verbose', count=True)
def log(verbose):
    click.echo('Verbosity: %s' % verbose)
          
        
          
$ log -vvv
Verbosity: 3
          
        

Jugando con Options

Flags

          
@click.command()
@click.option('--shout/--no-shout', default=False)
def info(shout):
    if shout:
        click.echo('Eeeeeeeyyyyy!!!!!')
    else:
        click.echo('Ey')
          
        

Jugando con Options

Switches

          
import sys

@click.command()
@click.option('--upper', 'transformation', flag_value='upper',
              default=True)
@click.option('--lower', 'transformation', flag_value='lower')
def info(transformation):
    click.echo(getattr(sys.platform, transformation)())
          
        
          
$ info --upper
DARWIN
$ info --lower
darwin
$ info
DARWIN
          
        

Jugando con Options

Opción con multiples elecciones

          
@click.command()
@click.option('--hash-type', type=click.Choice(['md5', 'sha1']))
def digest(hash_type):
    click.echo(hash_type)
          
        

Jugando con Options

Prompting

          
@click.command()
@click.option('--name', prompt=True)
def hello(name):
    click.echo('Hello %s!' % name)
          
        
          
$ hello --name=John
Hello John!
$ hello
Name: John
Hello John!
          
        

Jugando con Options

Preguntando la password

          
@click.command()
@click.password_option()
def encrypt(password):
    click.echo('Encrypting password to %s' % password.encode('rot13'))

#
# Asi seria sin el decorator @click.password_option()
#
# @click.command()
# @click.option('--password', prompt=True, hide_input=True,
#              confirmation_prompt=True)
# def encrypt(password):
#    click.echo('Encrypting password to %s' % password.encode('rot13'))
          
        
          
$ encrypt
Password: 
Repeat for confirmation: 
Encrypting password to frperg
          
        

Jugando con Options

Recogiendo variables del environment

          
@click.command()
@click.option('--username')
def greet(username):
    click.echo('Hello %s!' % username)

if __name__ == '__main__':
    greet(auto_envvar_prefix='GREETER')
          
        
          
$ export GREETER_USERNAME=john
$ greet
Hello john!
          
        

Jugando con Options

Recogiendo variables del environment, alguna en particular

          
@click.command()
@click.option('--username', envvar='USERNAME')
def greet(username):
    click.echo('Hello %s!' % username)

if __name__ == '__main__':
    greet()
          
        
          
$ export USERNAME=john
$ greet
Hello john!
          
        

Jugando con Options

Usando callbacks en las opciones

          
def validate_rolls(ctx, param, value):
    try:
        rolls, dice = map(int, value.split('d', 2))
        return (dice, rolls)
    except ValueError:
        raise click.BadParameter('rolls need to be in format NdM')

@click.command()
@click.option('--rolls', callback=validate_rolls, default='1d6')
def roll(rolls):
    click.echo('Rolling a %d-sided dice %d time(s)' % rolls)

if __name__ == '__main__':
    roll()
          
        
          
roll --rolls=42
Usage: roll [OPTIONS]

Error: Invalid value for "--rolls": rolls need to be in format NdM

$ roll --rolls=2d12
Rolling a 12-sided dice 2 time(s)
          
        

¿Jugamos con los argumentos?

Jugando con argumentos

Lo básico!

          
@click.command()
@click.argument('filename')
def touch(filename):
    click.echo(filename)
          
        
          
$ touch foo.txt
foo.txt
          
        

Jugando con argumentos

Argumentos de número variable

          
@click.command()
@click.argument('src', nargs=-1)
@click.argument('dst', nargs=1)
def copy(src, dst):
    for fn in src:
        click.echo('move %s to folder %s' % (fn, dst))
          
        
          
$ copy foo.txt bar.txt my_folder
move foo.txt to folder my_folder
move bar.txt to folder my_folder
          
        

Jugando con argumentos

Argumentos de número variable

          
@click.command()
@click.argument('input', type=click.File('rb'))
@click.argument('output', type=click.File('wb'))
def inout(input, output):
    while True:
        chunk = input.read(1024)
        if not chunk:
            break
        output.write(chunk)
          
        
          
$ inout - hello.txt
hello
^D
$ inout hello.txt -
hello
          
        

Jugando con argumentos

Argumentos con paths

          
@click.command()
@click.argument('f', type=click.Path(exists=True))
def touch(f):
    click.echo(click.format_filename(f))
          
        
          
$ touch hello.txt
hello.txt

$ touch missing.txt
Usage: touch [OPTIONS] F

Error: Invalid value for "f": Path "missing.txt" does not exist.
          
        

¿Y ahora?

  • Documentación
  • Entrada de usuario
  • Utilidades

Documentación de comandos

          
@click.command()
@click.argument('name')
def say_hello(name):
    """This script say hello to user NAME."""
    click.echo('Hello %s!' % name)
          
        
          
$ say_hello --help
Usage: say_hello [OPTIONS] NAME

  This script say hello to user NAME.

Options:
  --help           Show this message and exit.
          
        

Documentación de comandos

          
@click.group()
def cli():
    """A simple command line tool."""

@cli.command('init', short_help='init the repo')
def init():
    """Initializes the repository."""

@cli.command('delete', short_help='delete the repo')
def delete():
    """Deletes the repository."""
          
        
          
$ repo.py
Usage: repo.py [OPTIONS] COMMAND [ARGS]...

  A simple command line tool.

Options:
  --help  Show this message and exit.

Commands:
  delete  delete the repo
  init    init the repo
          
        

Entrada de usuarios

          
value = click.prompt('Please enter a valid integer', type=int)
          
        
          
value = click.prompt('Please enter a number', default=42.0)
          
        

Entrada de usuarios

(pedir confirmación de algo)

          
if click.confirm('Do you want to continue?'):
    click.echo('Well done!')
          
        
          
click.confirm('Do you want to continue?', abort=True)
          
        

Utilidades

ANSI colorines!

          
import click

click.echo('Hello World!')

click.echo('Hello World!', err=True)

click.secho('Hello World!', fg='green')
click.secho('Some more text', bg='blue', fg='white')
click.secho('ATTENTION', blink=True, bold=True)
          
        

Utilidades

Paginación

          
@click.command()
def less():
    click.echo_via_pager('\n'.join('Line %d' % idx
                                   for idx in range(200)))
          
        

Utilidades

Lanzando editores

          
import click

def get_commit_message():
    MARKER = '# Everything below is ignored\n'
    message = click.edit('\n\n' + MARKER)
    if message is not None:
        return message.split(MARKER, 1)[0].rstrip('\n')
          
        
          
click.edit(filename='/etc/passwd')
          
        

Otras utilidades

  • Lanzar aplicaciones
  • Barras de progreso
  • Click para continuar
  • Apertura de ficheros inteligente

Comandos y grupos de comandos!

(ahora viene lo divertido)

Pasando contextos a comandos anidados!

          
@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
    ctx.obj['DEBUG'] = debug

@cli.command()
@click.pass_context
def sync(ctx):
    click.echo('Debug is %s' % (ctx.obj['DEBUG'] and 'on' or 'off'))

if __name__ == '__main__':
    cli(obj={})
          
        
          
$ cmd --debug sync
Debug is on
$ cmd sync
Debug is off
          
        

Hackeando un comando

(Ojo! mueve el ratón que hay mas código abajo ;-) )

          
import click
import os

plugin_folder = os.path.join(os.path.dirname(__file__), 'commands')

class MyCLI(click.MultiCommand):

    def list_commands(self, ctx):
        rv = []
        for filename in os.listdir(plugin_folder):
            if filename.endswith('.py'):
                rv.append(filename[:-3])
        rv.sort()
        return rv

    def get_command(self, ctx, name):
        ns = {}
        fn = os.path.join(plugin_folder, name + '.py')
        with open(fn) as f:
            code = compile(f.read(), fn, 'exec')
            eval(code, ns, ns)
        return ns['cli']

@click.command(cls=MyCLI)
def cli():
    pass
          
        

Mezclando multiples grupos de comandos

          
import click

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

@cli1.command()
def cmd1():
    """Command on cli1"""

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

@cli2.command()
def cmd2():
    """Command on cli2"""

cli = click.CommandCollection(sources=[cli1, cli2])

if __name__ == '__main__':
    cli()
          
        

Encadenando multiples comandos

          
@click.group(chain=True)
def cli():
    pass


@cli.command('sdist')
def sdist():
    click.echo('sdist called')


@cli.command('bdist_wheel')
def bdist_wheel():
    click.echo('bdist_wheel called')
          
        
          
$ setup.py sdist bdist_wheel
sdist called
bdist_wheel called
          
        

Valores por defecto en contextos

          
import click

CONTEXT_SETTINGS = dict(
    default_map={'runserver': {'port': 5000}}
)

@click.group(context_settings=CONTEXT_SETTINGS)
def cli():
    pass

@cli.command()
@click.option('--port', default=8000)
def runserver(port):
    click.echo('Serving on http://127.0.0.1:%d/' % port)

if __name__ == '__main__':
    cli()
          
        
          
$ cli runserver
Serving on http://127.0.0.1:5000/
          
        

Invocando otros comandos de un comando

          
cli = click.Group()

@cli.command()
@click.option('--count', default=1)
def test(count):
    click.echo('Count: %d' % count)

@cli.command()
@click.option('--count', default=1)
@click.pass_context
def dist(ctx, count):
    ctx.forward(test)
    ctx.invoke(test, count=42)
          
        
          
$ cli dist
Count: 1
Count: 42
          
        

¿Preguntas?

Gracias!


Puedes consultar la presentación en esta URL

http://telemaco.github.io/python-click

Enlaces