Skip to content

Latest commit

ย 

History

History
535 lines (374 loc) ยท 12.3 KB

README.md

File metadata and controls

535 lines (374 loc) ยท 12.3 KB

Pydantic Typer

PyPI - Version PyPI - Python Version Test Coverage

Typer extension to enable pydantic support

Warning

This package is still in early development and some things might not work as expected, or change between versions.


Table of Contents

Installation

pip install pydantic-typer

Note

pydantic-typer comes with pydantic and typer as dependencies, so you don't need to install anything else.

Usage

For general typer usage, please refer to the typer documentation.

All the code blocks below can be copied and used directly (they are tested Python files). To run any of the examples, copy the code to a file main.py, and run it:

python main.py

Basic Usage

๐Ÿง‘โ€๐Ÿ’ป Simply use pydantic_typer.run instead of typer.run to enable pydantic support

from typing import Annotated

import pydantic
import typer

import pydantic_typer


class User(pydantic.BaseModel):
    id: Annotated[int, pydantic.Field(description="The id of the user.")]
    name: Annotated[str, pydantic.Field(description="The name of the user.")] = "Jane Doe"


def main(num: int, user: User):
    typer.echo(f"{num} {type(num)}")
    typer.echo(f"{user} {type(user)}")


if __name__ == "__main__":
    pydantic_typer.run(main)
๐Ÿฆ– Non-Annotated Version
import pydantic
import typer

import pydantic_typer


class User(pydantic.BaseModel):
    id: int = pydantic.Field(description="The id of the user.")
    name: str = pydantic.Field("Jane Doe", description="The name of the user.")


def main(num: int, user: User):
    typer.echo(f"{num} {type(num)}")
    typer.echo(f"{user} {type(user)}")


if __name__ == "__main__":
    pydantic_typer.run(main)
๐Ÿ’ป Usage
$ # Run the basic example:
$ python main.py
Usage: main.py [OPTIONS] NUM
Try 'main.py --help' for help.
โ•ญโ”€ Error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Missing argument 'NUM'.                                            โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

$ # We're missing a required argument, try using --help as suggested:
$ python main.py --help
Usage: main.py [OPTIONS] NUM

โ•ญโ”€ Arguments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ *    num      INTEGER  [default: None] [required]                  โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
โ•ญโ”€ Options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ *  --user.id          INTEGER  The id of the user. [default: None] โ”‚
โ”‚                                [required]                          โ”‚
โ”‚    --user.name        TEXT     The name of the user.               โ”‚
โ”‚                                [default: Jane Doe]                 โ”‚
โ”‚    --help                      Show this message and exit.         โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

$ # Notice the help text for `user.id` and `user.name` are inferred from the `pydantic.Field`.
$ # `user.id` is reqired, because we don't provide a default value for the field.
$ # Now run the example with the required arguments:
$ python main.py 1 --user.id 1
1 <class 'int'>
id=1 name='Jane Doe' <class '__main__.User'>

$ # It worked! You can also experiment with an invalid `user.id`:
$ python main.py 1 --user.id some-string
Usage: example_001_basic.py [OPTIONS] NUM
Try 'example_001_basic.py --help' for help.
โ•ญโ”€ Error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Invalid value for '--user.id': 'some-string' is not a valid integer.โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

Usage with nested models

๐Ÿง‘โ€๐Ÿ’ป pydantic_typer.run also works with nested pydantic models

from __future__ import annotations

from typing import Optional

import pydantic
import typer

import pydantic_typer


class Pet(pydantic.BaseModel):
    name: str
    species: str


class Person(pydantic.BaseModel):
    name: str
    age: Optional[float] = None  # noqa: UP007 For Python versions >=3.10, prefer float | None
    pet: Pet


def main(person: Person):
    typer.echo(f"{person} {type(person)}")


if __name__ == "__main__":
    pydantic_typer.run(main)
๐Ÿ’ป Usage
$ # Run the nested models example with the required options:
$ python main.py --person.name "Patrick" --person.pet.name "Snoopy" --person.pet.species "Dog"
name='Patrick' age=None pet=Pet(name='Snoopy', species='Dog') <class '__main__.Person'>

Use pydantic models with typer.Argument

๐Ÿง‘โ€๐Ÿ’ป You can annotate the parameters with typer.Argument to make all model fields CLI arguments

from __future__ import annotations

import pydantic
import typer
from typing_extensions import Annotated

import pydantic_typer


class User(pydantic.BaseModel):
    id: int
    name: str


def main(num: Annotated[int, typer.Option()], user: Annotated[User, typer.Argument()]):
    typer.echo(f"{num} {type(num)}")
    typer.echo(f"{user} {type(user)}")


if __name__ == "__main__":
    pydantic_typer.run(main)
๐Ÿ’ป Usage
$ # Run the example
$ python main.py
Usage: main.py [OPTIONS] _PYDANTIC_USER_ID
                                         _PYDANTIC_USER_NAME
Try 'main.py --help' for help.
โ•ญโ”€ Error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ Missing argument '_PYDANTIC_USER_ID'.                               โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

$ #ย Notice how _PYDANTIC_USER_ID and _PYDANTIC_USER_NAME are now cli arguments instead of options.
$ # Supply the arguments in the right order:
> python main.py 1 Patrick --num 1
1 <class 'int'>
id=1 name='Patrick' <class '__main__.User'>
๐Ÿ’ก You can also override annotations directly on the pydantic model fields:
from __future__ import annotations

import pydantic
import typer
from typing_extensions import Annotated

import pydantic_typer


class User(pydantic.BaseModel):
    id: Annotated[int, typer.Argument(metavar="THE_ID")]
    name: Annotated[str, typer.Option()]


def main(num: Annotated[int, typer.Option()], user: Annotated[User, typer.Argument()]):
    typer.echo(f"{num} {type(num)}")
    typer.echo(f"{user} {type(user)}")


if __name__ == "__main__":
    pydantic_typer.run(main)

Here, User is a typer.Argument, but we manually override the fields again:

  • We override the metavar of to User.id be THE_ID
  • And User.name to be a typer.Option

Use pydantic models in multiple commands

๐Ÿง‘โ€๐Ÿ’ป For larger typer apps, you can use pydantic_typer.Typer instead of annotating each command function individually to enable pydantic models on all commands

from __future__ import annotations

import pydantic
import typer
from typing_extensions import Annotated

from pydantic_typer import Typer

app = Typer()


class User(pydantic.BaseModel):
    id: int
    name: Annotated[str, typer.Option()] = "John"


@app.command()
def hi(user: User):
    typer.echo(f"Hi {user}")


@app.command()
def bye(user: User):
    typer.echo(f"Bye {user}")


if __name__ == "__main__":
    app()

Use pydantic types

๐Ÿง‘โ€๐Ÿ’ป You can also annotate arguments with pydantic types and they will be validated

import click
import typer
from pydantic import HttpUrl, conint

import pydantic_typer

EvenInt = conint(multiple_of=2)


def main(num: EvenInt, url: HttpUrl, ctx: click.Context):  # type: ignore
    typer.echo(f"{num} {type(num)}")
    typer.echo(f"{url} {type(url)}")


if __name__ == "__main__":
    pydantic_typer.run(main)

๐Ÿง‘โ€๐Ÿ’ป Pydantic types also work in lists and tuples

from typing import List

import typer
from pydantic import AnyHttpUrl

import pydantic_typer


def main(urls: List[AnyHttpUrl] = typer.Option([], "--url")):
    typer.echo(f"urls: {urls}")


if __name__ == "__main__":
    pydantic_typer.run(main)

Use Union types

๐Ÿง‘โ€๐Ÿ’ป Thanks to pydantic.TypeAdapter, which we use internally, we also support Union types

import typer

import pydantic_typer


def main(value: bool | int | float | str = 1):
    typer.echo(f"{value} {type(value)}")


if __name__ == "__main__":
    pydantic_typer.run(main)
๐Ÿ’ป Usage
$ # Run the example using a boolean
$ python main.py --value True
True <class 'bool'>

$ # Run the example using an integer
$ python main.py --value 2
2 <class 'int'>

$ # Run the example using a float
$ python main.py --value 2.1
2.1 <class 'float'>

$ # Run the example using a string
$ python main.py --value "Hello World"
Hello World <class 'str'>

$ # Before, we intentionally used 2, when testing the integer
$ # Check what happens if you pass 1
$ python main.py --value 1
True <class 'bool'>

$ # We get back a boolean!
$ # This is because Unions are generally evaluated left to right.
$ # So in this case bool > int > float > str, if parsing is successful.
$ # There are some exceptions, where pydantic tries to be smart, see here for details:
$ # https://docs.pydantic.dev/latest/concepts/unions/#smart-mode

Limitations

Warning

pydantic-typer does not yet support sequences of pydantic models: See this issue for details

Warning

pydantic-typer does not yet support self-referential pydantic models.

Warning

pydantic-typer does not yet support lists with complex sub-types, in particular unions such as list[str|int].

License

pydantic-typer is distributed under the terms of the MIT license.