Skip to content
Thomas Byr edited this page Jun 2, 2026 · 2 revisions

Click-powered CLI

  1. Basic usage
  2. Using groups
    1. Defining a group
    2. Supplying commands
    3. Command aliases
    4. Setting a default sub-command for a group
    5. Creating a group inside of a group
  3. Types
    1. Collections
    2. Ranges
    3. Choices
    4. Paths and files

The cli module we provide is a wrapper around useful click and rich-click functions.

Because we use a restricted set of those function, this module does not offer everything click and rich_click do. But since our closures do not modify any of the classes, everything is compatible with rich_click decorators.

The code samples all assume previous declarations as well as:

from nob import cli

Basic usage

Define a command and some option (default type is str) with:

@cli.cmd()
@cli.opt("--name", required=True, help="Greet someone.")
def hello(lg: cli.Logger, name: str):
    lg.info("Hello %s", name)


if __name__ == "__main__":
    hello()

The cli.cmd decorator will supply:

  • cfg: The Config object nob.cli.Config
  • ctx: The Click context object nob.cli.Context (alias for rich_click.Context)
  • lg: A logger with the name of the command nob.cli.Logger (alias for logging.Logger)

if any of those is expected or if your command definition accepts **kwargs.

Running

uv run <file.py> --name "Eric Norbert"

will log "Hello Eric Norbert" with severity logging.INFO.

You can always use cli.arg("name") instead of @cli.opt("--name") to define a positional argument.

Using groups

We will assume your file ends with:

if __name__ == "__main__":
    main()

Defining a group

This is useful for CLIs like uv run <file.py> <command>. Simply define a main group with:

@cli.grp()
def main(): ...  # actual implementation of a group! nothing goes in here

Supplying commands

A group is nothing without commands. You can create as many commands as you like. Use the cli.cmd decorator while supplying the group:

@cli.cmd(main)
def hello(lg: cli.Logger):
    lg.info("hello")

The cli.cmd decorator supplies the same additional arguments as in Basic usage.

Alongside with our hello command, we can also define:

@cli.opt("-l", "--log", is_flag=True, help="Prints log messages.")
@cli.cmd(main)
def test(lg: cli.Logger, log: bool = False):
    if log:
        lg.debug("Debug")

Calling test -l won't print anything unless you supply --verbose or -v to the main group.

For example:

uv run <file.py> hello

will log "Hello" with severity logging.INFO, and

uv run <file.py> -v test -l

will log "Debug" with severity logging.INFO.

Command aliases

Groups are aliased in Nob. Meaning by default, all commands within a group are matched as long as 1) the first letters match and 2) there is no ambiguity.

For example h, he, hel and hell will all match command hello. If we were to define a hi command, then h would raise an error.

You can define your own aliases by creating a file ./assets/cfg/default.yml like so:

aliases:
    greet: hello

or any other places and provide it at runtime with uv run <file.py> -c <your-file.yml> greet. The YAML file is automatically loaded if it exists in the default location.

Setting a default sub-command for a group

You might not want to always type the default command for your CLI. We provide a way of supplying a default sub-command as well a feeding the arguments.

Suppose we now define:

@cli.opt("--name", type=str)
@cli.cmd(main)
def hello(lg: cli.Logger, name: str | None = None):
    lg.info("Hello %s", name or "")

we could define our main group as follow

@cli.grp(default=lambda: hello, name="Eric")
def main(): ...

So that uv run <file.py> calls hello --name Eric instead of printing help.

Note

uv run <file.py> --name Eric won't work and will fail with No such command '--name'..

Creating a group inside of a group

Sometimes it makes sense to nest groups to regroup the application logic (from the users perspective). The cli.grp decorator accepts a grp= parameter to point to a parent group.

@cli.grp(main)
def server(): ...

@cli.cmd(server)
def start():
    pass

@cli.cmd(server)
def stop():
    pass

Types

Both cli.opt and cli.arg support type= assignment to hint the type to Click. We forward some non-basic types from rich_click to nob.cli.types.

In addition to basic python types, Click provides:

Collections

You may provide an option multiple times:

@cli.opt("-n", "--name", type=str, multiple=True)
@cli.cmd()
def hello(name: tuple[str, ...]):
    print("Hello", ", ".join(name))

Ranges

You may check for ranges directly in the CLI. For example:

@cli.opt("-a", "--age", type=cli.types.IntRange(1, 99))
@cli.cmd()
def hello(age: int):
    print(age)

You can also use FloatRange a similar way.

Choices

from nob.utils.auto_numbered_enum import AutoNumberedEnum


class HashType(AutoNumberedEnum):
    MD5 = ()
    SHA1 = ()


@cli.opt("--hash-type",
         type=cli.types.Choice(HashType, case_sensitive=False))
@cli.cmd()
def digest(hash_type: HashType):
    print(hash_type)

You may provide an Iterable instead of an Enum.

Paths and files

@cli.opt("--file", required=True,
         type=cli.types.Path(exists=True, dir_okay=False))
@cli.cmd()
def read(file: str):
    with open(file) as f:
        print(f.read())

The type cli.types.File returns the opened file instead of the path as a str.

Clone this wiki locally