typer icon indicating copy to clipboard operation
typer copied to clipboard

✨ Support for pydantic options

Open pypae opened this issue 10 months ago • 9 comments

📝 Summary

This PR adds support for pydantic options as requested in the following issue: #111 In particular it implements the behavior I described in https://github.com/tiangolo/typer/issues/111#issuecomment-643147865.

The implementation changes the signature of the callback to add all fields of the (possibly nested) pydantic models of the original callback as typer.Options. As a result the implementation is largely independent of typer and could also be used as a standalone package which could be activated with an additional decorator on the command functions:

Usage as a decorator (draft)
import pydantic
import typer
from typer_pydantic import unwrap_pydantic_models


class User(pydantic.BaseModel):
    id: int
    name: str = "Jane Doe"

@unwrap_pydantic_models
def main(num: int, user: User):
    print(num, type(num))
    print(user, type(user))


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

🔀 Related PRs

  • https://github.com/tiangolo/typer/pull/723 (complementary)
  • https://github.com/tiangolo/typer/pull/329 (alternative)
  • https://github.com/tiangolo/typer/pull/630 (alternative)

☑️ To Do

  • [x] Make linter happy
  • [ ] Describe advantages over other approaches
  • [ ] Add support for typer.Option and typer.Argument inside pydantic models
  • [x] Add tests for nested pydantic models
  • [ ] Make sure validation errors are reported properly
  • [x] Make pydantic dependency optional
  • [ ] ~~Allow sequences of pydantic models.~~ I suggest implementing indexed list options first, see the comment below.

pypae avatar Apr 24 '24 10:04 pypae

📝 Docs preview for commit bfa4e6d9c18a2bbf60563ee5cd54a40927ca4ac3 at: https://c0ad6c82.typertiangolo.pages.dev

github-actions[bot] avatar Apr 24 '24 10:04 github-actions[bot]

📝 Docs preview for commit a6a7004b758a08816c9ece6021e1bdb87bf2fdae at: https://efd73f25.typertiangolo.pages.dev

github-actions[bot] avatar Apr 24 '24 19:04 github-actions[bot]

📝 Docs preview for commit 3e37bb5e1152a3497cd9a61c6a919dd8660ce250 at: https://f4caeab2.typertiangolo.pages.dev

github-actions[bot] avatar Apr 24 '24 19:04 github-actions[bot]

📝 Docs preview for commit 6c76ab9437ee91085ab47b4d1bdd8447ccc712b4 at: https://cc7a8711.typertiangolo.pages.dev

github-actions[bot] avatar Apr 24 '24 20:04 github-actions[bot]

📝 Docs preview for commit ebb58772d751036ae79ed5e86e1389835e4c9e95 at: https://6cfbeacd.typertiangolo.pages.dev

github-actions[bot] avatar Apr 25 '24 12:04 github-actions[bot]

📝 Docs preview for commit 12be77ecd65946027ff5caba9cc66169eda73310 at: https://ab20f60a.typertiangolo.pages.dev

github-actions[bot] avatar Apr 25 '24 13:04 github-actions[bot]

Problem

If we want to allow sequences of pydantic models, things get unreadable quite fast.

Let's look at an example of a nested pydantic model with a sequence of another model: pets.py

from typing import Optional, List

import typer

import pydantic


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


class Person(pydantic.BaseModel):
    name: str
    age: Optional[float] = None
    pets: List[Pet]


def main(person: Person):
    print(person, type(person))


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

The script could be called like this:

$ python pets.py --person.name Jeff --person.pets.name Lassie --person.pets.species dog

If we want to add multiple pets, we can just supply --person.pets.name and person.pets.species multiple times.

$ python pets.py --person.name Jeff --person.pets.name Lassie --person.pets.species dog --person.pets.name Nala --person.pets.species cat

We don't explicitly state which pet names and species belong together and have to rely on the correct order of parameters. In my opinion this is potentially confusing for the CLI user and may lead to bugs.

Potential Solution

To make the mapping more explicit, we could allow to enable typer.Option lists to be indexed. Like for nested pydantic models, I suggest sticking to the syntax traefik uses for lists. I.e. entrypoints.<name>.http.tls.domains[n].main

Indexed lists could be implemented independently of this PR and should work for all lists. I suggest adding an indexed flag on typer.Option like shown in the example below.

indexed_list.py

from typing import List

import typer

def main(indexed_list: List[int] = typer.Option(..., indexed=True)):
    print(indexed_list)

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

This would then produce the following help text:

$ python indexed_list.py --help
                                                                                
 Usage: indexed_list.py [OPTIONS]                                    
                                                                    
╭─ Options ────────────────────────────────────────────────────────╮
│ *  --indexed-list[n]        INTEGER   [default: None] [required] │
│    --help                             Show this message and      │
│                                       exit.                      │
╰──────────────────────────────────────────────────────────────────╯

And could be used like this:

$ python indexed_list.py --indexed-list[1] 0 --indexed-list[0] 1 --indexed-list[2] 2
[1, 0, 2]

Note how the order of the input parameters doesn't matter anymore because the indices are given explicitly.

Notes on Implementation

Implementing this might not be trivial, but I think it could be possible by forwarding unknown options as described in the click docs.

Edit: This might actually be easier using token normalization.


Do you have any opinions on this?

pypae avatar Apr 26 '24 12:04 pypae

@pypae why limiting the pydantic models to typer.Option? It would be nice to be able to define arguments in the pydantic model as well, for example:

class User(BaseModel):
        name: Annotated[str, typer.Argument()]      
        lastname: Annotated[str, typer.Option()]            

    @app.command()
    def cmd(user: User):
        pass

My current use case requires a set of commands with a client argument, so this would allow code reuse.

If we want to allow sequences of pydantic models, things get unreadable quite fast [...] In my opinion this is potentially confusing for the CLI user and may lead to bugs

I'm not sure how many users would required deep nested commands in their CLI, and if that's the case using a toml or yaml config file may be a better way to do that. Adding a warning note at the doc should be enough for this feature.

itepifanio avatar May 02 '24 14:05 itepifanio

I started working on a standalone package to support this functionality, so you can use pydantic models with typer right now: https://github.com/pypae/pydantic-typer.

It's still WIP, and for now only covers the same basic behavior as this PR.

pypae avatar Jul 19 '24 13:07 pypae