CLI framework

Architecture

Overview

cli2 is built on 3 moving parts which you can swap with your own or inherit from with ease:

  • Command: Represents a target callback, in charge of CLI args parsing and execution, can serve as entry point.

  • Group: Same as above, except that it routes multiple Commands, can serve as entry point as well.

  • Argument: Represents a target callback argument, in charge of deciding if it wants to take an argument as well as casting it into a Python value.

All outputs are fed into print(), meaning the outputs are colored too. Any kind of output will work: normal return, generator, async coroutine, async generator.

Tutorial

Functions

In general, you want to create a command Group:

"""
Welcome to your CLI
"""
import cli2

cli =  cli2.Group(doc=__doc__)

@cli.cmd
def yourcmd(somearg: str):
    """
    Your own command.

    :param somearg: It's some string argument that this function will return
    """
    return somearg


if __name__ == '__main__':
    cli.entry_point()

Entrypoint

You can also add this command to console_scripts in setup.py:

setup(
    # ...
    entry_points={
        'console_scripts': [
            'your-cli-name = your_cli:cli.entry_point',
    },
)

Classes and objects

load() will load all methods if they have been decorated with @cmd:

class YourStuff:
    @classmethod
    @cli2.cmd
    def factory(cls):
        return cls()

    @cli2.cmd(color='green')
    def instance_method(self, arg):
        return arg

cli = cli2.Group()
cli.load(YourStuff)
# you'll leverage the factory override to hide an argument from the CLI and
# instead provide a callable (they can be async too)
cli.overrides['self']['factory'] = lambda: YourStuff()
cli.overrides['cls']['factory'] = lambda: YourStuff

Example

The command group will look a bit like this:

_images/group.png

The command itself like that:

_images/command.png

_cli2

If you need to know if a function is executed from cli2, you can add a _cli2 special keyword argument:

def your_cmd(*args, _cli2=None):
    if _cli2:
        print('in cli2')
        if not args:
            return _cli2.help(error='Send me some args please!')
    else:
        return some

_cli2 will be the Command instance if detected in function signature.

Posix style

You might prefer to have dashes in front of argument names in the typical style of command lines, you just need to enable the posix attribute:

cli2.cli(yourcmd, posix=True).entry_point()

In this case, help will look like this:

_images/example2posix.png

Warning

I still don’t use the POSIX mode, it’s far from perfect, but I’ll gladly try to fix bugs!

Testing

Direct calls

The parse() method will provision the bound attribute which is a Python 3 BoundArguments instance, so you could test parsing as such:

cmd = cli2.cli(yourcmd)
cmd.parse('a', 'b', 'c=d')
assert cmd.bound.arguments == dict(somearg='a', x='b', kwargs={'c': 'd'})

Same if you want to use the posix style:

cmd = cli2.cli(yourcmd, posix=True)
cmd.parse('a', 'b', '--c=d')
assert cmd.bound.arguments == dict(somearg='a', x='b', kwargs={'c': 'd'})

autotest

cli2.test.autotest(path, cmd, ignore=None, env=None)[source]

The autowriting test pattern, minimal for testing cli2 scripts.

Example:

from cli2.test import autotest
autotest(
    'tests/djcli_save_user.txt',
    'djcli save auth.User username="test"',
)

Argument

Factory

You may want some arguments to have automatically computed values instead of being exposed to the user CLI. This is what argument factories are for:

class Foo:
    @cli2.arg('auto', factory=lambda: 'autoval')
    @cli2.arg('self', factory=lambda cmd, arg: Foo())
    def test(self, auto, arg):
        return auto, arg

cli2.cli(Foo.test)

This command will only expose the arg argument to the user. Both self and auto will have the result of the lambda passed as factory.

If the factory callback takes an arg argument, then the Argument object will be passed.

If the factory callback takes an cmd argument, then the Command object will be passed.

Aliases

By default, named arguments are given aliases (CLI argument names) generated from their Python argument names. For example:

def yourcmd(foo=True):
    print(foo)
cmd = cli2.cli(yourcmd)
cmd.help()

Will render help as such:

_images/example_alias.png

Posix

If posix mode is enabled, then a couple of dashes will prefix the Python argument name, and another one-letter-long alias with a single dash will be generated.

_images/example_alias_posix.png

Overrides

You may overrides Argument attributes for a callable argument with the arg() decorator:

@cli2.arg('foo', alias='bar')
def yourcmd(foo):
    pass

This also takes a list of aliases:

@cli2.arg('foo', alias=['foo', 'f', 'foooo'])
def yourcmd(foo):
    pass

This decorator basically sets yourcmd.cli2_foo to a dict with the alias key.

Hide

You can also hide an argument from CLI:

import cli2

@cli2.hide('foo', 'bar')
def yourcmd(a, foo=None, bar=None):
    pass

Integers

Type hinting is well supported, the following example enforces conversion of an integer argument:

def yourcmd(i : int):
    pass

cmd = cli2.cli(yourcmd)
cmd.parse('1')
assert cmd.bound.arguments == dict(i=1)

Boolean

Declare a boolean type hint for an argument as such:

def yourcmd(yourbool : bool):

You won’t have to specify the value of a boolean argument, but if you want to then:

  • for False: no, 0, false

  • for True: yes, 1, true, anything else

Values don’t need to be specified, which means that you don’t have to type yourbool=true, just yourbool or --yourbool in POSIX mode will set it to True.

Since the mere presence of argument aliases suffice to bind a parameter to True, an equivalent is also possible to bind it to False: negate. It is by default generated by prefixing no- to the argument name, as such, passing no-yourbool on the command line will bind yourbool to False, or in posix mode by passing --no-yourbool. Note that a single-dash two-letter negate is also generated in posix mode, so -ny would also work to bind yourbool to False.

False

While the negates are set by default on boolean arguments, you may also set it on non-boolean arguments, just like you could override it like you would override aliases:

@cli2.arg('yourbool', negate='--no-bool')
def yourcmd(yourbool):

List and Dicts

Arguments annotated as list or dict will have CLI values automatically casted to Python using JSON.

def yourcmd(foo: list):
    print(foo)

But be careful with spaces on your command line: one sysarg goes to one argument:

yourcmd ["a","b"]   # works
yourcmd ["a", "b"]  # does not because of the space

However, space is supported as long as in the same sysarg:

subprocess.check_call(['yourcmd', '["a", "b"]')

Typable lists and dicts

So, the above will work great when called by another program, but not really nice to type. So, another syntax for the purpose of typing is available and works as follow.

Arguments with the list type annotation are automatically parsed as JSON, if that fails it will try to split by commas which is easier to type than JSON for lists of strings:

yourcmd a,b  # calls yourcmd(["a", "b"])

Keep in mind that JSON is tried first for list arguments, so a list of ints is also easy:

yourcmd [1,2]  # calls yourcmd([1, 2])

A simple syntax is also supported for dicts by default:

yourcmd a:b,c:d  # calls yourcmd({"a": "b", "c": "d"})

The disadvantage is that JSON decode exceptions are swallowed, but by design cli2 is supposed to make Python types more accessible on the CLI, rather than being a JSON validation tool. Generated JSON args should always work though.

Custom type casting

You may also hack how arguments are casted into python values at a per argument level, using decorator syntax or the lower level Python API.

For example, you can override the cast() method for a given argument as such:

@cli2.args('ages', cast=lambda v: [int(i) for i in v.split(',')])
def yourcmd(ages):
    return ages

cmd = Command(yourcmd)
cmd(['1,2']) == [1, 2]  # same as CLI: yourcmd 1,2

You can also easily write an automated test:

cmd = cli2.cli(yourcmd)
cmd.parse('1,2')
assert cmd.bound.arguments == dict(ages=[1, 2])

Overridding default code

Argument overriding

Overriding an Argument class can be useful if you want to heavily customize an argument, here’s an example with the age argument again:

class AgesArgument(cli2.cli):
    def cast(self, value):
        # logic to convert the ages argument from the command line to
        # python goes in this method
        return [int(i) for i in value.split(',')]

@cli2.arg('ages', cls=AgesArgument)
def yourcmd(ages):
    return ages

assert yourcmd('1,2') == [1, 2]

Command class overriding

Overriding the Command class can be useful to override how the target callable will be invoked.

Example:

class YourThingCommand(cli2.cli):
    def call(self, *args, **kwargs):
        # do something
        return self.target(*args, **kwargs)

@cli2.cmd(cls=YourThingCommand)
def yourthing():
    pass

cmd = cli2.cli(yourthing)  # will be a YourThingCommand

You may also override at the group level, basically instanciate your Group: with the cmdclass argument:

cli = cli2.cli(cmdclass=YourThingCommand)
cli.add(your_function)

CLI only arguments

A more useful example combining all the above, suppose you have two functions that take a “schema” argument that is a python object of a “Schema” class of your own.

class Schema(dict):
    def __init__(self, filename, syntax):
        """ parse file with given syntax ..."""

@cli.cmd
def build(schema):
    """ build schema """

@cli.cmd
def manifest(schema):
    """ show schema """

In this case, overriding the schema argument with custom casting won’t work because the schema argument is built with two arguments: filename in syntax!

Solution:

class YourCommand(cli2.cli):
    def setargs(self):
        super().setargs()

        # hide the schema argument from CLI
        del self['schema']

        # create two arguments programatically
        self.arg(
            'filename',
            position=0,
            kind='POSITIONAL_ONLY',
            doc='File to use',
        )
        self.arg(
            'syntax',
            kind='KEYWORD_ONLY',
            doc='Syntax to use',
        )

    def call(self, *args, **kwargs):
        schema = Schema(
            self['filename'].value,
            self['syntax'].value,
        )
        return self.target(schema, *args, **kwargs)

@cli.cmd
def build(schema):
    """ build schema """

@cli.cmd
def manifest(schema):
    """ show schema """

There you go, you can automate command setup like with the creation of a schema argument and manipulate arguments programatically!

Check cli2/test_inject.py for edge cases and more fun examples!

Edge cases

Simple and common use cases were favored over rarer use cases by design. Know the couple of gotchas and you’ll be fine.

Args containing = when **kwargs is present

Simple use cases are favored over rarer ones when a callable has varkwargs.

When a callable has **kwargs as such:

def foo(x, **kwargs):
    pass

Then, arguments that look like kwargs will be attracted to the kwargs argument, so if you want to call foo("a=b") then you need to call as such:

foo x=a=b

Because the following will call foo(a='b'), and fail because of missing x, which is more often than not what you want on the command line:

foo a=b

Now, even more of an edgy case when *args, **kwargs are used:

def foo(*args, **kwargs):
    return (args, kwargs)

Call foo("a", b="x") on the CLI as such:

foo a b=x

BUT, to call foo("a", "b=x") on the CLI you will need to use an asterisk with a JSON list as such:

foo '*["a","b=x"]'

Admittedly, the second use case should be pretty rare compared to the first one, so that’s why the first one is favored.

For the sake of consistency, varkwarg can also be specified with a double asterisk and a JSON dict as such:

# call foo("a", b="x")
foo a **{"b":"x"}

Calling with a="b=x" in (a=None, b=None)

The main weakness is that it’s difficult to tell the difference between a keyword argument, and a keyword argument passed positionnaly which value starts with the name of another keyword argument. Example:

def foo(a=None, b=None):
    return (a, b)

Call foo(b='x') on the CLI like this:

foo b=x

BUT, to call foo(a="b=x") on the CLI, you need to name the argument:

foo a=b=x

Admitadly, that’s a silly edge case. Protect yourself from it by always naming keyword arguments …

… Because the parser considers token that start with a keyword of a keyword argument prioritary to positional arguments once the positional arguments have all been bound.

API

class cli2.cli.Argument(cmd, param, doc=None, color=None, factory=None, hide=False, **kwargs)[source]

Class representing a bound parameter and command line argument.

property accepts

Return True if this argument still accepts values to bind.

aliasmatch(arg)[source]

Return True if the CLI arg matches an alias of this argument.

cast(value)[source]

Cast a string argument from the CLI into a Python object.

factory_value(cmd=None)[source]

Run the factory function and return the value.

If the factory function takes a cmd argument, it will pass the command object.

If the factory function takes an arg argument, it will pass self.

It will forward any argument to the factory function if detected in it’s signature, except for *args and **kwargs.

Parameters:

cmd – Override for cmd, useful for getting the factory value of an argument from another class (advanced).

help()[source]

Render help for this argument.

property iskw

Return True if this argument is not positional.

match(arg)[source]

Return the value extracted from a matching CLI argument.

take(arg)[source]

Return False if it doesn’t accept this arg, otherwise bind it.

property value

Return the value bound to this argument.

exception cli2.cli.Cli2Error[source]
exception cli2.cli.Cli2ValueError[source]
class cli2.cli.Command(target, *args, **kwargs)[source]

Represents a command bound to a target callable.

arg(name, *, kind: str = None, position: int = None, doc=None, color=None, default, annotation)[source]

Inject new Argument into this command.

The new argument will appear in documentation, but won’t be bound to the callable: it will only be avalaible in self.

For example, you are deleting an “http_client” argument in setargs() so that it doesn’t appear to the CLI user, to whom you want to expose a couple of arguments such as “base_url” and “ssl_verify” that you are adding programatically with this method, so that you can use self[‘base_url’].value and self[‘ssl_verify’].value in to generate a “http_client” argument in call().

The tutorial has a more comprehensive example in the “CLI only arguments” section.

Parameters:
  • name – Name of the argument to add

  • kind – Name of the inspect parameter kind

  • position – Position of the argument in the CLI

  • doc – Documentation for the argument

  • color – Color of the argument

  • default – Default value for the argument

  • annotation – Type of argument

async async_call(*argv)[source]

Call with async stuff in single event loop

async_function(function)[source]

Return True if function is async

async_mode()[source]

Return True if any callable we’ll deal with is async

call(*args, **kwargs)[source]

Execute command target with bound arguments.

async factories_resolve()[source]

Resolve all factories values.

help(error=None, short=False, missing=None)[source]

Show help for a command.

items(factories=False)[source]

Return ordered items.

Parameters:

factories – Show only arguments with factory.

keys(factories=False)[source]

Return ordered keys.

Parameters:

factories – Show only arguments with factory.

ordered(factories=False)[source]

Order the parameters by priority.

Parameters:

factories – Show only arguments with factory.

parse(*argv)[source]

Parse arguments into BoundArguments.

post_call()[source]

Implement your cleaner here

setargs()[source]

Reset arguments.

values(factories=False)[source]

Return ordered values.

Parameters:

factories – Show only arguments with factory.

class cli2.cli.Group(name=None, doc=None, color=None, posix=False, overrides=None, outfile=None, cmdclass=None, log=True)[source]

Represents a group of named commands.

add(target, *args, **kwargs)[source]

Add a new target as sub-command.

cmd(*args, **kwargs)[source]

Decorator to add a command with optionnal overrides.

group(name, grpclass=None, **kwargs)[source]

Return a new sub-group.

help(*args, error=None, short=False)[source]

Get help for a command or group.

Parameters:
  • args – Command or sub-command chain to show help for.

  • error – Error message to print out.

  • short – Show short documentation.

load_cls(cls, leaf=None, cmdclass=None)[source]

Load all methods which have been decorated with @cmd

Note that you can define conditions, this is how we hide functions such as create/delete/get from models without url_list:

@cli2.cmd(condition=lambda cls: cls.url_list)
load_obj(obj, cmdclass=None)[source]

Load all methods which have been decorated with @cmd

class cli2.cli.Overrides[source]

Lazy overrides dict

cli2.cli.arg(name, **kwargs)[source]

Set the overrides for an argument.

cli2.cli.cmd(*args, **overrides)[source]

Set the overrides for a command.