Custom types and callbacks

Click · Advanced · 12 min

Domain-specific parsing such as durations or key=value pairs belongs in reusable types, not command bodies.

When the built-in types are not enough, subclass click.ParamType:

  • set name (shown in help and error messages),
  • implement convert(self, value, param, ctx), returning the converted value or calling self.fail(message, param, ctx) to produce a proper usage error.

convert must accept values that are already converted: defaults, prompts and programmatic calls may pass the target type rather than a string. Start with if isinstance(value, TargetType): return value. Since Click 8.4, ParamType is generic, so you can annotate it as ParamType[timedelta] for type checkers.

For one-off validation, a callback is lighter. callback=fn on a parameter is called as fn(ctx, param, value) after conversion, and its return value replaces the value. Raise click.BadParameter("...") to reject it. Callbacks are good for cross-checks and normalisation.

Eager parameters (is_eager=True) are processed before the others. Combined with expose_value=False and a callback that calls ctx.exit(), they implement options like --version or --list-formats that do something immediately and stop, even if required parameters are missing. Callbacks run even when the value was not supplied, so check value (and ctx.resilient_parsing, which is set during shell completion) before acting.

For custom version output, Click 8.5 adds click.custom_version_option; version_option covers the standard case.

Example

import re
from datetime import timedelta
import click

class Duration(click.ParamType):
    name = "duration"
    def convert(self, value, param, ctx):
        if isinstance(value, timedelta):
            return value
        m = re.fullmatch(r"(\d+)([smh])", value)
        if not m:
            self.fail(f"{value!r} is not like 30s, 5m or 2h", param, ctx)
        unit = {"s": "seconds", "m": "minutes", "h": "hours"}[m[2]]
        return timedelta(**{unit: int(m[1])})

def even_only(ctx, param, value):
    if value % 2:
        raise click.BadParameter("must be even")
    return value
@click.command()
@click.option("--timeout", type=Duration(), default="30s")
@click.option("--workers", type=int, default=2, callback=even_only)
def run(timeout, workers):
    click.echo(f"timeout={timeout.total_seconds():.0f}s workers={workers}")
run.main(["--timeout", "5m", "--workers", "4"], standalone_mode=False)  # timeout=300s workers=4

Remember

  • Subclass ParamType with name and convert(); call self.fail for bad input
  • convert must also accept already-converted values
  • callback=(ctx, param, value) validates; is_eager + ctx.exit() for --version-style options

Watch out for

  • Writing convert() that assumes the value is always a string, breaking defaults of the target type.

Check yourself

  1. Inside a custom ParamType.convert, how should you reject an invalid value?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: click.palletsprojects.com/en/stable/parameter-types/