Skip to main content

Class-based definitions of click commands

Project description

$ 🎩click✨_, classyclick

ci test codecov PyPI pyversions Current version on PyPi Very popular Code style: black

Class-based definitions of click commands

pip install classyclick

A Simple Example

import click
import classyclick


@classyclick.command()
class Hello:
    """Simple program that greets NAME for a total of COUNT times."""

    name: str = classyclick.Option(prompt='Your name', help='The person to greet.')
    count: int = classyclick.Option(default=1, help='Number of greetings.')

    def __call__(self):
        for _ in range(self.count):
            click.echo(f'Hello, {self.name}!')


if __name__ == '__main__':
    Hello.click()
$ python hello.py --count=3
Your name: classyclick
Hello, classyclick!
Hello, classyclick!
Hello, classyclick!

Wait... huh?

This simple example has even more lines than click's example???

Right, apart from personal aesthetics preferences, there is no reason to choose class-approach in this example.

Reason why I started to use classes for commands is that, as the command function complexity grows, we decompose it into more functions:

import click

@click.command()
@click.option("--count", default=1, help="Number of greetings.")
@click.option("--name", prompt="Your name", help="The person to greet.")
def hello(count, name):
    """Simple program that greets reversed NAME for a total of COUNT times."""
    greet(count, name)


def greet(count, name):
    for _ in range(count):
        click.echo(f"Hello, {reverse(name)}!")

def reverse(name):
    return name[::-1]

See the parameters being passed around?
Easy to have multiple parameters required to several different functions.

Refactoring to classyclick:

import click
import classyclick


@classyclick.command()
class Hello:
    """Simple program that greets NAME for a total of COUNT times."""

    name: str = classyclick.Option(prompt='Your name', help='The person to greet.')
    count: int = classyclick.Option(default=1, help='Number of greetings.')

    def __call__(self):
        self.greet()
    
    def greet(self):
        for _ in range(self.count):
            click.echo(f"Hello, {self.reversed_name}!")
    
    @property
    def reversed_name(self):
        return self.name[::-1]

More docs please

Not much to add to the simple example currently, as this mostly forwards everything to click, but here's something more then!

classyclick.command

Use it just like @click.command but decorating a class instead of a function (classy).

The only new keyword argument is group. This can be used to attach the command a click.group.

Re-using click examples:

@click.group()
@click.option('--debug/--no-debug', default=False)
def cli(debug):
    click.echo(f"Debug mode is {'on' if debug else 'off'}")

@cli.command()  # @cli, not @click!
def sync():
    click.echo('Syncing')

@classyclick.command(group=cli)  # classy! with group
class AnotherSync:
    ...

Same as click.command, you can choose a command name or allow it to derive it from class name (camel to kebab, instead of click's snake to kebab).

It will also forward class __doc__ to click to be used as description if not specified as keyword arg.

classyclick.Option

Instead of the decorator approach, this is more like Django's models to take advantage of how parameters are enumerated.

As you noticed from the example, there's no need to specify an option parameter name:

count: int = classyclick.Option(default=1, help='Number of greetings.')

classyclick makes use of the field names to infer a default (--count in example).

To add a short version on top of it:

count: int = classyclick.Option('-c', default=1, help='Number of greetings.')

And to only include the short, you can use the only keyword argument that is not forwarded to @click.option: default_parameter

count: int = classyclick.Option('-c', default_parameter=False, default=1, help='Number of greetings.')

classyclick.Option also infers type from type hints, then passed to click.option.

# The resulting click.option will use type=Path
output: Path = classyclick.Option()

# You can still override it and mix things if you want ¯\_(ツ)_/¯
other_output: Any = classyclick.Option(type=str)

When type is bool, it will set is_flag=True as well. If for some reason you don't want that, it can still be overriden.

# This results in click.option('--verbose', type=bool, is_flag=True)
verbose: bool = classyclick.Option()

# As mentioned, it can always be overriden if you need the weird behavior of a non-flag bool option...
weird: bool = classyclick.Option(is_flag=False)

classyclick.Argument

Similar to classyclick.Option, this is mostly wrapping @click.argument so it can be used in fields.

Argument name is inferred from the field name and, same as classyclick.Option, type from field.type.
Again, type can be overriden, however not argument name as it has to match the property. For display purposes, you can use metavar=.

@classyclick.command()
class Next:
    """Output the next number."""

    your_number: int = classyclick.Argument()

    def __call__(self):
        click.echo(self.your_number + 1)
$ ./cli_four.py --help
Usage: cli_four.py [OPTIONS] YOUR_NUMBER

  Output the next number.

Options:
  --help  Show this message and exit.

$ ./cli_four.py 5     
6

classyclick.Context

Like @click.pass_context, this exposes click.Context in a command property.

@classyclick.command()
class Next:
    """Output the next number."""

    your_number: int = classyclick.Argument()
    the_context: Any = classyclick.Context()

    def __call__(self):
        click.echo(self.your_number + self.the_context.obj.step_number)

classyclick.ContextObj

Like @click.pass_obj, this assigns click.Context.obj to a command property, when you only want the user data rather than the whole context.

@classyclick.command()
class Next:
    """Output the next number."""

    your_number: int = classyclick.Argument()
    the_context: Any = classyclick.ContextObj()

    def __call__(self):
        click.echo(self.your_number + self.the_context.step_number)

classyclick.ContextMeta

Like @click.pass_meta_key, this assigns click.Context.meta[KEY] to a command property, without handling the whole context.

@classyclick.command()
class Next:
    """Output the next number."""

    your_number: int = classyclick.Argument()
    step_number: int = classyclick.ContextMeta("step_number")

    def __call__(self):
        click.echo(self.your_number + self.step_number)

Composition

You can compose commands together as the wrapped class is just a dataclass.

As example, if we wanted a Bye command just like the Hello example above, but with a small change, we can subclass Hello

import click
import classyclick


@classyclick.command()
class Bye(Hello):
    """Simple program that says bye to NAME for a total of COUNT times."""

    def greet(self):
        for _ in range(self.count):
            click.echo(f"Bye, {self.reversed_name}!")

The command is subclassed, inheriting arguments/options (as they are dataclass fields) and any methods:

$ ./bye.py --help

Usage: bye.py [OPTIONS]

  Simple program that says bye to NAME for a total of COUNT times.

Options:
  --name TEXT          The person to greet.
  -c, --count INTEGER  Number of greetings.
  --help               Show this message and exit.

Testing

classyclick is just a small wrapper around click, testing is the same as in click's docs:

Simply use Command.click with CliRunner for the same click.testing experience

from click.testing import CliRunner
# Hello being the example above that reverses name
from hello import Hello

def test_hello_world():
  runner = CliRunner()
  result = runner.invoke(Hello.click, ['--name', 'Peter'])
  assert result.exit_code == 0
  assert result.output == 'Hello reteP!\n'

But you can also unit test specific methods of a command, skipping CliRunner.

This might help reducing required test setup as you don't need to control complex code paths from entrypoint of the CLI command.

from hello import Hello

def test_hello_world():
# for the example above that reverses the name
o = Hello('hello', 1)
assert o.reversed_name == 'olleh'

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

classyclick-0.6.1.tar.gz (15.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

classyclick-0.6.1-py3-none-any.whl (9.6 kB view details)

Uploaded Python 3

File details

Details for the file classyclick-0.6.1.tar.gz.

File metadata

  • Download URL: classyclick-0.6.1.tar.gz
  • Upload date:
  • Size: 15.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.10.17

File hashes

Hashes for classyclick-0.6.1.tar.gz
Algorithm Hash digest
SHA256 9b4e7e2f542e9c4d78cda9dcca5b52c87dedf8ced1b4353b4d5aa7500be71f81
MD5 a3b21dd204a88a5f3aee4ca15278aa92
BLAKE2b-256 0b2d105d4a1be3f8a9436d8bbf64792c675d01afbae7e23dad50a737c92d717a

See more details on using hashes here.

File details

Details for the file classyclick-0.6.1-py3-none-any.whl.

File metadata

  • Download URL: classyclick-0.6.1-py3-none-any.whl
  • Upload date:
  • Size: 9.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.10.17

File hashes

Hashes for classyclick-0.6.1-py3-none-any.whl
Algorithm Hash digest
SHA256 4ac05cde4536665fe1a7da91479924d5c4fc09f025292f770ee47fa5124aca2f
MD5 336ffe618d02ba16744d3d635820e4da
BLAKE2b-256 57d70cab4dbd3bcf21477c00dce2f785b1af998590e1e7e5d4caacdf117b52a6

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page