typer
typer copied to clipboard
✨ Support for pydantic options
📝 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.Option
s. 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
andtyper.Argument
insidepydantic
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.
📝 Docs preview for commit bfa4e6d9c18a2bbf60563ee5cd54a40927ca4ac3 at: https://c0ad6c82.typertiangolo.pages.dev
📝 Docs preview for commit a6a7004b758a08816c9ece6021e1bdb87bf2fdae at: https://efd73f25.typertiangolo.pages.dev
📝 Docs preview for commit 3e37bb5e1152a3497cd9a61c6a919dd8660ce250 at: https://f4caeab2.typertiangolo.pages.dev
📝 Docs preview for commit 6c76ab9437ee91085ab47b4d1bdd8447ccc712b4 at: https://cc7a8711.typertiangolo.pages.dev
📝 Docs preview for commit ebb58772d751036ae79ed5e86e1389835e4c9e95 at: https://6cfbeacd.typertiangolo.pages.dev
📝 Docs preview for commit 12be77ecd65946027ff5caba9cc66169eda73310 at: https://ab20f60a.typertiangolo.pages.dev
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 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.
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.