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

subcommand parsing with pydantic v1 models does not work. #71

Open
sander76 opened this issue Sep 12, 2023 · 5 comments
Open

subcommand parsing with pydantic v1 models does not work. #71

sander76 opened this issue Sep 12, 2023 · 5 comments

Comments

@sander76
Copy link
Contributor

When using pydantic v1 and creating a model with subcommands, the first model is always used.
See the test below. Using pydantic v1 this fails.

def test_pydantic_model_with_sub_commands_and_positionals__success():
    class SubCommandOne(BaseModel):
        name: str

    class SubCommandTwo(BaseModel):
        name: str

    class ModelWithSubcommand(BaseModel):
        sub_command: SubCommandOne | SubCommandTwo

    model = cli(
        ModelWithSubcommand,
        args=["sub-command:sub-command-two", "--sub-command.name", "myname"],
    )
    assert isinstance(model.sub_command, SubCommandTwo)
    assert model.sub_command.name == "myname"
@brentyi
Copy link
Owner

brentyi commented Sep 12, 2023

Hi @sander76!

tyro isn't doing anything special here, this seems like a result of Pydantic's internal union logic.

The error persists if we remove the tyro bits and instantiate things directly:

from pydantic import BaseModel


class SubCommandOne(BaseModel):
    name: str


class SubCommandTwo(BaseModel):
    name: str


class ModelWithSubcommand(BaseModel):
    sub_command: SubCommandOne | SubCommandTwo


# Prints `SubcommandOne`.
print(
    type(
        ModelWithSubcommand(
            sub_command=SubCommandTwo(name="myname"),
        ).sub_command
    )
)

Bot tyro and the vanilla pydantic usages are "fixed" once the fields of SubcommandOne and SubcommandTwo start differing.

Given this, do you think there's anything we could / should do here?

@sander76
Copy link
Contributor Author

Hmm. Didn't expect your example to happen. But I think I do know why it does and I have seen it before, but not in your case where you explicitly define the sub_command.

But I guess the following is happening (and I think this is the case for tyro too)
Consider your above example and populate it using a dict:

model = ModelWithSubcommand(sub_command={"name":"myname"})

Pydantic has no way of knowing what the type of sub_command is. So it tries to instantiate all models going through the list of Unions. The first one that doesn't raise an issue, will be used. In this case both models have both the name as required property and the first model in the list of unions, SubCommandOne doesn't raise and is thus used.

Pydantic has the option of smart_union making Union check more thorough. This should work (but don't know how to exactly implement this in tyro):

from pydantic import BaseModel


class SubCommandOne(BaseModel):
    name: str


class SubCommandTwo(BaseModel):
    name: str


class ModelWithSubcommand(BaseModel):
    sub_command: SubCommandOne | SubCommandTwo

    class Config:
        smart_union = True


# Prints `SubcommandOne`.
print(
    ModelWithSubcommand(
        sub_command=SubCommandTwo(name="myname"),
    )
)

@brentyi
Copy link
Owner

brentyi commented Sep 13, 2023

Cool!

Adding the smart_union = True flag makes the assert in your test pass for me:

from pydantic import BaseModel
from tyro import cli

class SubCommandOne(BaseModel):
    name: str

class SubCommandTwo(BaseModel):
    name: str

class ModelWithSubcommand(BaseModel):
    sub_command: SubCommandOne | SubCommandTwo

    class Config:
        smart_union = True

model = cli(
    ModelWithSubcommand,
    args=["sub-command:sub-command-two", "--sub-command.name", "myname"],
)
assert isinstance(model.sub_command, SubCommandTwo)
assert model.sub_command.name == "myname"

@brentyi
Copy link
Owner

brentyi commented Sep 13, 2023

To comment a bit on what's happening internally: tyro is correctly instantiating a SubcommandTwo object in your original (failing) test, but when this is passed to ModelWithSubcommand older versions of pydantic seem to automatically throw away the SubcommandTwo instance and re-instantiate a SubcommandOne one. This is basically the same as what happens in the tyro-free example I included above.

I'm not sure that a workaround is even possible, but since this is resolved in newer versions of Pydantic my feeling is we probably don't need to worry about it on our end?

@sander76
Copy link
Contributor Author

Ah. I see. I guess you're right.

But to prevent you from doing a lot of "needless" work, maybe even drop support for pydantic v1 altogether ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants