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

The old decorator-based command declaration and lowercase helpers are no longer supported. If you're using an older release, see the 0.11.0 documentation.

A Simple Example

import click

import classyclick


class Hello(classyclick.Command):
    """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()
$ ./cli_hello_simple.py --name classyclick --count=3
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


class Hello(classyclick.Command):
    """Simple program that greets reversed 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

classyclick stays very close to click, but the public API is centered around Command, Group, and field declarations.

classyclick.Command

Subclass classyclick.Command and implement __call__.

The generated click command is exposed as YourCommand.click, which you can invoke directly or attach to a group.

Command-level click-specific configuration lives in __config__:

Re-using click examples:

class Greet(classyclick.Group):
    """Greeting commands."""

    debug: bool = classyclick.Option('--debug/--no-debug')

    def __call__(self):
        click.echo(f'Debug mode is {"on" if self.debug else "off"}')


class Hello(Greet.Command):
    """Say hello."""

    __config__ = classyclick.Command.Config(group=Greet)

    name: str = classyclick.Option(prompt='Your name')

    def __call__(self):
        click.echo(f'Hello, {self.name}!')

As with click.command, you can choose a command name explicitly or let it derive from the class name (camel-case to kebab-case).

The class docstring is forwarded to click using inspect.getdoc, so inherited descriptions are used when no explicit help text is configured.

If you were previously using @classyclick.command(...), the class-based equivalent is to subclass classyclick.Command and move those keyword arguments into classyclick.Command.Config(...).

classyclick.Option

Options are declared as class fields, similar to Django models.

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 passes it 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 wraps 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=.

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

    your_number: int = classyclick.Argument()

    def __call__(self):
        click.echo(self.your_number + 1)
$ ./cli_next.py --help
Usage: cli_next.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.

class NextGroup(classyclick.Group):
    the_context: click.Context = classyclick.Context()

    def __call__(self):
        self.the_context.obj = SimpleNamespace(step_number=4)


class Next(NextGroup.Command):
    """Output the next number."""

    your_number: int = classyclick.Argument()
    the_context: click.Context = 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.

class Next(NextGroup.Command):
    """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.

class NextGroupMeta(classyclick.Group):
    the_context: click.Context = classyclick.Context()

    def __call__(self):
        self.the_context.meta['step_number'] = 5


class Next(NextGroupMeta.Command):
    """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)

classyclick.helpers

classyclick.helpers contains optional helpers for larger CLIs.

One useful pattern is a root group that loads config defaults plus a built-in config command to inspect them:

from pathlib import Path

import click

import classyclick


class CLI(classyclick.helpers.ConfigFileMixin, classyclick.Group):
    """Application CLI."""

    __config__ = classyclick.Group.Config(
        context_settings=dict(show_default=True),
        decorators=[click.version_option(version='1.2.3', message='%(version)s')],
    )
    CONFIG_DEFAULT_NAME = 'my-app'
    CONFIG_EXAMPLE_PATH = Path(__file__).parent / 'config.example.toml'

    host: str = classyclick.Option(help='Server URL')
    token: str = classyclick.Option(help='API token')
    debug: bool = classyclick.Option(help='Enable debug logging')

    def __call__(self):
        self.load_config()


class Config(classyclick.helpers.ConfigBaseCommand, CLI.Command):
    pass


# in package/commands/__init__.py
classyclick.helpers.discover_commands(__package__)

discover_commands() is usually called from package.commands.__init__.py when each command lives in its own module. It recursively imports submodules so command classes register themselves under the group.

It can also be called from elsewhere, such as package.__init__.py, by pointing it at the commands package directly with classyclick.helpers.discover_commands(f'{__package__}.commands').

ConfigFileMixin adds --config and --env, loads config.toml, merges [env.<name>] sections, and uses matching keys as defaults for classyclick fields. Explicit command-line flags still win over config values.

ConfigBaseCommand is a ready-made command that shows the merged config or opens the active config file in $VISUAL or $EDITOR.

config.toml

config.toml is meant to mirror your CLI options. Root-level keys become defaults for matching fields, and default_env selects a named [env.<name>] section to merge on top.

default_env = "dev"
host = "https://api.example.com"

[env.dev]
token = "dev-token"
debug = true

[env.prod]
token = "prod-token"

With this file:

  • my-app status uses the dev environment by default
  • my-app --env prod status merges the prod overrides over the root config
  • my-app --env prod --host https://staging.example.com status still uses prod, but the CLI flag overrides host

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
from cli_hello import Hello


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:

$ ./cli_bye.py --help
Usage: cli_bye.py [OPTIONS]

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

Options:
  --name TEXT      The person to greet.
  --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 .cli_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 .cli_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-1.0.3.tar.gz (27.8 kB view details)

Uploaded Source

Built Distribution

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

classyclick-1.0.3-py3-none-any.whl (18.0 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: classyclick-1.0.3.tar.gz
  • Upload date:
  • Size: 27.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.20

File hashes

Hashes for classyclick-1.0.3.tar.gz
Algorithm Hash digest
SHA256 2f7274fd81a7d47238bb66b5fdc594d0a29dcccae047fc6a20552daa5861f0b8
MD5 deeef3d821c66224dd8604acea87495d
BLAKE2b-256 34863ea5c8b7c705034bf46adb058b740ea773a4b4e8af736b64cee3ab9b078c

See more details on using hashes here.

File details

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

File metadata

  • Download URL: classyclick-1.0.3-py3-none-any.whl
  • Upload date:
  • Size: 18.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.10.20

File hashes

Hashes for classyclick-1.0.3-py3-none-any.whl
Algorithm Hash digest
SHA256 a18aeff3ac241f497553f8a42a7c5453c5a6838a1d53e2dacb1b73d717718622
MD5 2b727f0a4131e1022f228360733138a6
BLAKE2b-256 b54ceff97a80ca395598aef786ccf946100ce1025aca7af5334795714d66f91f

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