Skip to main content

$ 🎩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.

Group.Config(auto_envvar_prefix='GREET') enables Click's automatic environment variables from the root group (for example, GREET_HELLO_NAME). Set show_envvar=True there to display generated environment-variable names in the help for every option in the group and its subgroups. An individual option can override that default with classyclick.Option(show_envvar=False).

class CLI(classyclick.Group):
    __config__ = classyclick.Group.Config(
        auto_envvar_prefix='MYAPP',
        show_envvar=True,
        context_settings=dict(show_default=True),
    )

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.

By default, the field is required for direct construction, and Click raises KeyError if the metadata key is missing. Set default= to provide a fallback for direct construction and for CLI invocation when the key is absent; a value present in click.Context.meta takes precedence.

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(
        auto_envvar_prefix='MY_APP',
        show_envvar=True,
        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. Use --full to show the complete configuration, or -l / --list to list only the configured environments.

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"

The former default_env key and [env.<name>] tables remain supported for compatibility, but emit DeprecationWarnings. If both forms define the same environment or default, the new _env / _default_env form takes precedence.

To reuse values for selected commands, define tables under [_shared] and reference them from command tables with _shared:

[_shared.api]
api_url = 'https://api.example.com'

[_shared.auth]
token = 'secret'

[_shared.service]
_shared = ['api', 'auth']
retries = 3

[fetch]
_shared = ['service']
output = 'results.json'

[publish]
_shared = ['api', 'auth']

Referenced sections merge left to right; each section's own values and then command-local values override references. Shared sections can reference other shared sections, with cycles reported as config errors. _shared is reserved config metadata.

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'

Metadata

Release files for classyclick 1.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for classyclick 1.1.1
File Size Uploaded Provenance Actions
classyclick-1.1.1.tar.gz 32.6 kB Without provenance Details for classyclick-1.1.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for classyclick 1.1.1
File Interpreter ABI Platform Actions
classyclick-1.1.1-py3-none-any.whl Python 3 none any Details for classyclick-1.1.1-py3-none-any.whl

Total release size: 53.2 kB

Release files / classyclick-1.1.1.tar.gz

Download URL classyclick-1.1.1.tar.gz
Size 32.6 kB
Tags Source
SHA-256 checksum
How to use checksums
c4b72fd0629079e81a29dbcb1bc4ee0328f582ee3d7387b7624dd4d83e47cb94
BLAKE2b-256 checksum
How to use checksums
914e4a3c97b077f36ca97c1c3ca91b06878aa7030ceae49b455e37a4e55f40be
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.10.22

Release files / classyclick-1.1.1-py3-none-any.whl

Download URL classyclick-1.1.1-py3-none-any.whl
Size 20.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b22c086791248f9b0be95ad3d0800994cb5e23fe5ecc87285ed9b8c1c9132e80
BLAKE2b-256 checksum
How to use checksums
661bd26bb4e1fe7a33361f2f18d9fff969708e2c8e476b7f009073dbe8f7d6be
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.10.22

Security for classyclick 1.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page