Description
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.