Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial attempt at pydantic support. #630

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

gkarg
Copy link

@gkarg gkarg commented Jun 30, 2023

This PR builds on #111 (comment) by @sm-hawkfish / @ananis25
and attempts to implement #111 (comment) as described by @pypae

To re-state the problem this tries to solve:

  1. Typer, being "FastAPI of CLIs" ought to support pydantic models, I was shocked to find out it doesn't 🤷‍♂️
  2. The proposed, and in my opinion, very sensible user-facing interface would be:
#!/usr/bin/env python3

import click
import pydantic
import typer


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


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


if __name__ == "__main__":
    typer.run(main)
$ ./typer_demo.py 1 --user.id 2 --user.name "John Doe"

1 <class 'int'>
id=2 name='John Doe' <class '__main__.User'>

This PR basically implements this, with one major (and many minor) problems:

  • it's not possible to register arguments/options with . (dot) in the name in click itself :(
  • so for the time being, I'm going for -- delimiter, and it looks like this:
#!/usr/bin/env python3

import datetime
from typing import Any, Callable, Dict, Optional, List

import click
import pydantic
import typer

class DriverLicense(pydantic.BaseModel):
    license_id: str
    expires_at: datetime.datetime

class User(pydantic.BaseModel):
    id: int
    name = "Jane Doe"
    main_license: Optional[DriverLicense] = None
    driver_licenses: List[DriverLicense] = []


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


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

->

Usage: tcli.py [OPTIONS] NUM

Arguments:
  NUM  [required]

Options:
  --user--id INTEGER
  --user--main-license--license-id TEXT
  --user--main-license--expires-at [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
  --user--driver-licenses--license-id TEXT
  --user--driver-licenses--expires-at [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
  --user--name TEXT               [default: Jane Doe]
  --help                          Show this message and exit.

which is, admittedly, very ugly, but oh well.

Beside that, I've yet to test all corner cases and weird field/param combinations (and write proper tests), and probably think long and hard, if things like

class User(pydantic.BaseModel):
     ...

def main(user: Annotated[User, typer.Argument[...]):
    ...

should/shouldn't be allowed, etc.

However, I'd love to first hear your thoughts on the viability of this PR in general.

@github-actions
Copy link

📝 Docs preview for commit 1e0334f at: https://649ef33e50d4bf086d7df752--typertiangolo.netlify.app

@MrT3acher
Copy link

I tested it. It's great. thanks.

but why you are parsing arguments as below:

cli --model--field1 value1 --model--field2 value2

it isn't better to do like this:

cli --model-field1 value1 --model-field2 value2

or:

cli --model.field1 value1 --model.field2 value2

@gkarg
Copy link
Author

gkarg commented Jul 27, 2023

Hi, thanks.

As mentioned in the PR text, click itself (which is the basis for typer), does not allow . in argument names, at least not out of the box. I haven't yet investigated it further, maybe it can be done with an option, or as a feature request to click, or (worst scenario) as monkey-patch on click. But that means, sadly, that the most user-friendly notation, as in

cli --model.field1 value1 --model.field2 value2

is not available to us :(

Your other option,

cli --model-field1 value1 --model-field2 value2

is probably doable, but that would introduce ambiguity for distinguishing submodels and scalar fields. The closest example of resolving such ambiguity can sometimes be found in env var configs in some applications, e.g. AIRFLOW__DATABASE__SOME_URL (note two underscores for "section") -> airflow.database.some_url. Incedently I'm reusing some code from pydantic, that is capable of parsing env var in that exact format, and it seemed like a good idea.

Just to re-iterate, I do agree, that this format is very ugly, and would be much more happy with . notation.

@toppk
Copy link

toppk commented Aug 5, 2023

Just to give my two cents. I'm doing something like this (not fully implemented) but with attr, my poison of choice atm.

One thing that I'll add is that my use-case is similar to ssh's '-o' option, so I'm looking to use it's command line structure, which converted to your example would be:

Usage: tcli.py [OPTIONS] NUM

Arguments:
 NUM  [required]

Options:
 --user id=INTEGER
 --user main-license--license-id=TEXT
 --user main-license--expires-at=[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
 --user driver-licenses--license-id=TEXT
 --user driver-licenses--expires-at=[%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]
 --user name=TEXT               [default: Jane Doe]
 --help                          Show this message and exit.

doesn't look exactly right, since for the ssh use case the fields are clearly not intertwined as they are for your use-case. What I did for your use-case is just create a custom parser, which of course, doesn't get the pretty command line integration, but it is pretty usable.

Either way I would suggest working backwards from the desired command line, while it's possible to create what you have, this would start to break down really quickly if you did something like

def main(user: Annotated[list[User], typer.Argument[...]):
    ...

@djpugh
Copy link

djpugh commented Oct 15, 2023

@gkarg - thanks for this - I'd love to get pydantic (or any complex) models into the cli via this kind of logic! I'm relatively new to using typer (at least outside of quick tests), but familiar with click

As mentioned in the PR text, click itself (which is the basis for typer), does not allow . in argument names, at least not out of the box. I haven't yet investigated it further, maybe it can be done with an option, or as a feature request to click, or (worst scenario) as monkey-patch on click. But that means, sadly, that the most user-friendly notation, as in

I just tested with click version 8.1.7, and the option can take e.g. --a.b just not as the name

e.g.

@click.command()
@click.option('--a.b')
def test(**kwargs):
    print(kwargs)

Won't work, but:

@click.command()
@click.option('a_b', '--a.b')
def test(**kwargs):
    print(kwargs)

Will work with --a.b as the arg and the kwarg key as a_b - click requires names to be valid python identifiers - https://github.com/pallets/click/blob/ca5e1c3d75e95cbc70fa6ed51ef263592e9ac0d0/src/click/core.py#L2575.

Alternatively, but more clunkily, forwarding unprocessed args could handle it - that was how i did it previously (fragilely) when I was trying to do pydantic <> click directly? https://click.palletsprojects.com/en/8.1.x/advanced/#forwarding-unknown-options

In the past i tried to implement pydantic <> click handling directly, and di dmake use of that for complex args

@svlandeg svlandeg added feature New feature, enhancement or request p3 labels Feb 29, 2024
@pypae pypae mentioned this pull request Apr 24, 2024
7 tasks
@pypae
Copy link

pypae commented Apr 24, 2024

Note: I started another draft implementation for pydantic support in #803 which isn't as tightly coupled to typer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature New feature, enhancement or request p3
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants