Skip to content

No arguments to CLI subparser fails to raise an error #335

Closed
@mpkocher

Description

@mpkocher

With pydantic-settings 2.3.4, when no arguments are provided to a subparser style CLI, there is no exception raised.

I believe a user should not have to explicitly catch for these None cases on the "Root" Settings model.

Acceptance criteria:

  • An error is raised when no arguments are provided to a subparser
  • Add explicit test case for no arguments provided
  • Updated docs to include an example and suggested patterns on how to run a program (or function) using subcommands.

To demonstrate this issue:

import sys
from pydantic import BaseModel
from pydantic_settings import (BaseSettings, CliSubCommand)


class Alpha(BaseModel):
    """Alpha Utils"""
    name: str


class Beta(BaseModel):
    """Beta Utils"""
    age: int


class Root(BaseSettings, cli_parse_args=True, cli_prog_name="example"):
    alpha: CliSubCommand[Alpha]
    beta: CliSubCommand[Beta]



def runner_01(r: Root) -> int:
    print(f"Running {r}")
    return 0


if __name__ == '__main__':
    sys.exit(runner_01(Root()))

Running would be:

> python example.py                    
Running alpha=None beta=None
> echo $?
0

It's possible to workaround the current design by mapping these Optional fields of Root to a tuple, then case matching (alternatively, writing with if/elif).

def runner_02(r: Root) -> int:
    match (r.alpha, r.beta):
        case (None, None): raise ValueError(f"Invalid {r}")
        case (None, beta): print(f"Running beta {beta=}")
        case (alpha, None): print(f"Running {alpha=}")
        case (_, _): raise ValueError(f"Invalid {r}")
    return 0

However, it's a bit clunky and not particularly obvious that you need to handle these None cases. I believe the structural validation/checking should be occurring at the pydantic-settings level.

Misc Feedback

From a type standpoint, the current design is a bit peculiar because it leans on None.

class Root(BaseSettings, cli_parse_args=True, cli_prog_name="example"):
    alpha: CliSubCommand[Alpha] # sugar for Optional[_CliSubCommand]
    beta: CliSubCommand[Beta]

I believe the it would be useful to use a union type in the design.

class Root(BaseSettings, cli_parse_args=True, cli_prog_name="example"):
    command: Alpha | Beta

Calling a function/main would be:

def runner(m: Model):

    match m.command:
        case Alpha(): print(f"Running alpha={m.command}")
        case Beta(): print(f"Running beta={m.command}")

In general, it seems like there's a core part of the API that is missing the plumbing layer of calling your function with the initialized and validated settings.

There's also bit of friction with using mypy.

from pydantic_settings import BaseSettings


class Root(BaseSettings, cli_parse_args=True):
    name: str


if __name__ == "__main__":
    print(Root())

Yields

> mypy example.py         
example.py:9: error: Missing named argument "name" for "Root"  [call-arg]
Found 1 error in 1 file (checked 1 source file)

Perhaps this is general issue with pydantic-settings and should be filed as a separate ticket.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions