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.0

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.0
File Size Uploaded Provenance Actions
classyclick-1.1.0.tar.gz 32.5 kB Without provenance Details for classyclick-1.1.0.tar.gz

Built distribution (wheel)

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

Total release size: 53.1 kB

Release files / classyclick-1.1.0.tar.gz

Download URL classyclick-1.1.0.tar.gz
Size 32.5 kB
Tags Source
SHA-256 checksum
How to use checksums
9a4aa1707f349c8caba72d6f5085e066a072f5fd948c2fdef849c39592924ab3
BLAKE2b-256 checksum
How to use checksums
b29d47f2b9136f53d5f58d4577518a6f65357f5cbb3fc165fe5987bd49a53549
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.0-py3-none-any.whl

Download URL classyclick-1.1.0-py3-none-any.whl
Size 20.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6df112818581a0042bba1539b334569dfc77ebe14dca61c66ebd0249a4d4c3c3
BLAKE2b-256 checksum
How to use checksums
29ffc0647964e7e9c8bb46690a14f897c0f5adc3dcb9628ff0ef548e371d656e
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.0

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