Metadata-Version: 2.1
Name: classyclick
Version: 1.0.3
Summary: Class-based definitions of click commands
Author-email: Filipe Pina <shelf-corncob-said@duck.com>
Project-URL: Homepage, https://github.com/fopina/classyclick
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: BSD License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: click>=7
Requires-Dist: platformdirs>=4.4.0
Requires-Dist: tomli; python_version < "3.11"
Requires-Dist: typing-extensions>=4.12.2; python_version < "3.13"

# $ 🎩click✨_, _classyclick_

[![ci](https://github.com/fopina/classyclick/actions/workflows/publish-main.yml/badge.svg)](https://github.com/fopina/classyclick/actions/workflows/publish-main.yml)
[![test](https://github.com/fopina/classyclick/actions/workflows/test.yml/badge.svg)](https://github.com/fopina/classyclick/actions/workflows/test.yml)
[![codecov](https://codecov.io/github/fopina/classyclick/graph/badge.svg)](https://codecov.io/github/fopina/classyclick)
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/classyclick.svg)](https://pypi.org/project/classyclick/)
[![Current version on PyPi](https://img.shields.io/pypi/v/classyclick)](https://pypi.org/project/classyclick/)
[![Very popular](https://img.shields.io/pypi/dm/classyclick)](https://pypistats.org/packages/classyclick)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/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](https://classyclick.readthedocs.io/en/0.11.0/).

## A Simple Example

<!-- example-id: tests/cli_hello_simple.py -->
```python
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()
```

<!-- example-id-output: tests/cli_hello_simple.py --name classyclick --count=3 -->
```
$ ./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](https://github.com/pallets/click/blob/main/README.md#a-simple-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:

<!-- example-id: tests/cli_click_readme.py -->
```python
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:

<!-- example-id: tests/cli_hello.py --count=3 -->
```python
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:

<!-- example-id: tests/cli_click.py -->
```python
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](https://docs.djangoproject.com/en/dev/topics/db/models/).

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

<!-- example-id: tests/cli_short_samples.py:normal -->
```
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*:

<!-- example-id: tests/cli_short_samples.py:short -->
```
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](https://click.palletsprojects.com/en/stable/api/#click.option): `default_parameter`

<!-- example-id: tests/cli_short_samples.py:defaultparam -->
```
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`.

<!-- example-id: tests/cli_short_samples.py:type -->
```python
    # 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.

<!-- example-id: tests/cli_short_samples.py:bool -->
```python
    # 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`](https://click.palletsprojects.com/en/stable/api/#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=`.

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

    your_number: int = classyclick.Argument()

    def __call__(self):
        click.echo(self.your_number + 1)
```

<!-- example-id-output: tests/cli_next.py --help -->
```
$ ./cli_next.py --help
Usage: cli_next.py [OPTIONS] YOUR_NUMBER

  Output the next number.

Options:
  --help  Show this message and exit.
```

<!-- example-id-output: tests/cli_next.py 5 -->
```
$ ./cli_four.py 5     
6
```

### classyclick.Context

Like [`click.pass_context`](https://click.palletsprojects.com/en/stable/api/#click.pass_context), this exposes `click.Context` in a command property.

<!-- example-id: tests/cli_next_ctx.py 3 -->
```python
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`](https://click.palletsprojects.com/en/stable/api/#click.pass_obj), this assigns `click.Context.obj` to a command property when you only want the user data rather than the whole context.

<!-- example-id: tests/cli_next_ctx_obj.py 3 -->
```python
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`](https://click.palletsprojects.com/en/stable/api/#click.decorators.pass_meta_key), this assigns `click.Context.meta[KEY]` to a command property, without handling the whole context.

<!-- example-id: tests/cli_next_ctx_meta.py 3 -->
```python
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:

```python
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.

```toml
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`

<!-- example-id: tests/cli_bye.py --count=1 -->
```python
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:

<!-- example-id-output: tests/cli_bye.py --help -->
```
$ ./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](https://click.palletsprojects.com/en/stable/testing/#basic-testing):

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

<!-- example-id: tests/test_hello_readme2.py --name Peter -->
```python
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.

<!-- example-id: tests/test_hello_readme.py --name Peter -->
```python
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'
```
