felix-martel / pydanclick

Add click options from a Pydantic model
https://felix-martel.github.io/pydanclick/
MIT License
30 stars 8 forks source link
argparse cli click pydantic python

pydanclick

Release Build status codecov PyPI - Python Version License

Use Pydantic models as Click options.

Getting started

Install:

pip install pydanclick

Let's assume you have a Pydantic model:

class TrainingConfig(BaseModel):
    epochs: int
    lr: Annotated[float, Field(gt=0)] = 1e-4
    early_stopping: bool = False

Add all its fields as options in your Click command:

from pydanclick import from_pydantic

@click.command()
@from_pydantic(TrainingConfig)
def cli(training_config: TrainingConfig):
    # Here, we receive an already validated Pydantic object.
    click.echo(training_config.model_dump_json(indent=2))
~ python my_app.py --help
Usage: my_app.py [OPTIONS]

Options:
  --early-stopping / --no-early-stopping
  --lr FLOAT RANGE                [x>0]
  --epochs INTEGER                [required]
  --help                          Show this message and exit.

Features

Use native Click types

The following types are converted to native Click types:

Pydantic type Converted to
bool click.BOOL
str click.STRING
int click.INT
float click.FLOAT
Annotated[int, Field(lt=..., ge=...) click.IntRange()
Annotated[float, Field(lt=..., ge=...) click.FloatRange()
pathlib.Path click.Path()
uuid.UUID click.UUID
datetime.datetime, datetime.date click.DateTime()
Literal click.Choice

Complex container types such as lists or dicts are also supported: they must be passed as JSON strings, and will be validated through Pydantic TypeAdapter.validate_json method:

--arg1 '[1, 2, 3]' --arg2 '{"a": bool, "b": false}'

In any case, Pydantic validation will run during model instantiation.

Add multiple models

pydanclick.from_pydantic can be called several times with different models.

Use the prefix parameter to namespace the options from different models:

class Foo(BaseModel):
    a: str = ""
    b: str = ""

class Bar(BaseModel):
    x: int = 0
    y: int = 0

@click.command()
@from_pydantic(Foo, prefix="foo")
@from_pydantic(Bar, prefix="bar")
def cli(foo: Foo, bar: Bar):
    pass

will give:

~ python cli.py
Usage: cli.py [OPTIONS]

Options:
  --foo-a TEXT
  --foo-b TEXT
  --bar-x INTEGER
  --bar-y INTEGER
  --help           Show this message and exit.

Add regular options and arguments

pydanclick can be used alongside regular options and arguments:

@click.command()
@click.argument("arg")
@click.option("--option")
@from_pydantic(Foo)
def cli(arg, option, foo: Foo):
    pass

will give:

~ python cli.py
Usage: cli.py [OPTIONS] ARG

Options:
  --option TEXT
  --a TEXT
  --b TEXT
  --help         Show this message and exit.

Specify a custom variable name for the instantiated model with the same syntax as a regular Click option:

@click.command()
@from_pydantic("some_name", Foo)
def cli(some_name: Foo):
    pass

Document options

Options added with pydanclick.from_pydantic will appear in the command help page.

From docstrings: if griffe is installed, model docstring will be parsed and the Attributes section will be used to document options automatically (you can use pip install pydanclick[griffe] to install it). Use docstring_tyle to choose between google, numpy and sphinx coding style. Disable docstring parsing by passing parse_docstring=False.

From field description: pydanclick supports the Field(description=...) syntax from Pydantic. If specified, it will take precedence over the docstring description.

Explicitly: you can always specify a custom help string for a given field by using extra_options={"my_field": {"help": "my help string"}} where my_field is the name of your field.

Here are these three methods in action:

class Baz(BaseModel):
    """Some demo model.

    Attributes:
        a: this comes from the docstring (requires griffe)
    """

    a: int = 0
    b: Annotated[int, Field(description="this comes from the field description")] = 0
    c: int = 0

@click.command()
@from_pydantic(Baz, extra_options={"c": {"help": "this comes from the `extra_options`"}})
def cli(baz: Baz):
    pass

will give:

~ python cli.py --help
Usage: cli.py [OPTIONS]

Options:
  --a INTEGER  this comes from the docstring (requires griffe)
  --b INTEGER  this comes from the field description
  --c INTEGER  this comes from the `extra_options`
  --help       Show this message and exit.

Customize option names

Specify option names with rename and short option names with shorten:

@click.command()
@from_pydantic(Foo, rename={"a": "--alpha", "b": "--beta"}, shorten={"a": "-A", "b": "-B"})
def cli(foo: Foo):
    pass

will give:

~ python cli.py --help
Usage: cli.py [OPTIONS]

Options:
  -A, --alpha TEXT
  -B, --beta TEXT
  --help            Show this message and exit.

Note that prefix won't be prepended to option names passed with rename or shorten.

Pass extra parameters

Use extra_options to pass extra parameters to click.option for a given field.

For example, in the following code, the user will be prompted for the value of a:

@click.command()
@from_pydantic(Foo, extra_options={"a": {"prompt": True}})
def cli(foo: Foo):
    pass

Add nested models

Nested Pydantic models are supported, with arbitrary nesting level. Option names will be built by joining all parent names and the field names itself with dashes.

class Left(BaseModel):
    x: int

class Right(BaseModel):
    x: int

class Root(BaseModel):
    left: Left
    right: Right
    x: int

@click.command()
@from_pydantic(Root)
def cli(root: Root):
    pass

will give:

~ python cli.py --help
Usage: cli.py [OPTIONS]

Options:
  --left-x INTEGER   [required]
  --right-x INTEGER  [required]
  --x INTEGER        [required]
  --help             Show this message and exit.

To use rename, shorten, exclude, extra_options with a nested field, use its dotted name, e.g. left.x or right.x. Note that the alias of a field will apply to all its sub-fields:

@click.command()
@from_pydantic(Root, rename={"right": "--the-other-left"})
def cli(root: Root):
    pass

will give:

~ python cli.py --help
Usage: cli.py [OPTIONS]

Options:
  --left-x INTEGER            [required]
  --the-other-left-x INTEGER  [required]
  --x INTEGER                 [required]
  --help                      Show this message and exit.

Unpacking (experimental)

Unpacking provides a simpler API when working with list of submodels.

Consider the following example:

class Author:
    name: str
    primary: bool = False

class Book:
    title: str
    authors: list[Author]

@click.command()
@from_pydantic(Book, unpack_list=True)
def cli(book: Book):
    pass

By default, this would create two command-line arguments --title and --authors. Since authors has a complex type, it should be passed as a JSON string (e.g. --authors '[{"authors": {"name": "Alice", "primary": true}, {"name": "Bob"}]'). Usingunpacked_listwill instead "unpack" the nested fieldnameinto the main namespace: this new argument is called--authors-name` and can be specified multiple time, for example:

python cli.py --authors-name Alice --authors-primary --authors-name Bob

would create:

Book(authors=[Author(name="Alice", primary=True), Author(name="Bob")])

Note that you must always specify objects with optional arguments before objects without them. For example, the following command would make Bob the primary author, not Alice:

python cli.py --authors-name Bob --authors-name Alice --authors-primary

(Why? Because under the hood, arguments are collected per field {"name": [Bob, Alice], "primary": [True]}, and relative placement between fields cannot be accessed.)

When in doubt, you can simply specify all arguments:

python cli.py --authors-name Bob --no-authors-primary --authors-name Alice --authors-primary

This API is experimental and will not work in complex cases (deeply nested lists, lists of union, and much more). See issue #20 for context and details.

API Reference

Functions:

pydanclick.from_pydantic

from_pydantic(
    __var_or_model,
    model=None,
    *,
    exclude=(),
    rename=None,
    shorten=None,
    prefix=None,
    parse_docstring=True,
    docstring_style="google",
    extra_options=None
)

Decorator to add fields from a Pydantic model as options to a Click command.

Parameters:

Returns:

Contributing

Install the environment and the pre-commit hooks with

make install

Run tests with:

pytest

Limitations

pydanclick doesn't support (yet!):

Other missing features:


Repository initiated with fpgmaas/cookiecutter-poetry.