Possible to have dataclass-like parameters? #137
-
If I have a Dataclass-like structures are special in that they are promoted to command/subcommand status, but is there a way to tell Cappa "treat this not as a subcommand/unsatisfied dep, but rather treat it as a dict-like argument but with dotted attribute access"? In the workaround below, the Of course you might suggest unwrapping I also see the point of keeping dataclass-like classes single-purpose, so if there's no way to make dataclass-like classes act like arguments instead, I would understand the intent behind a design decision like that, especially if undoing that invariant adds overhead to every single class inference made in CLI parsing. from __future__ import annotations
from pathlib import Path
from typing import TypedDict
from cappa import command, invoke
from pydantic import BaseModel
class _Deps(BaseModel):
raw: Path = Path("raw")
other: Path = Path("other")
class Deps(TypedDict):
raw: Path
other: Path
def main(args: Binarize):
print(args.deps["raw"], args.deps["other"])
@command(invoke=main)
class Binarize(BaseModel):
deps: Deps = _Deps().model_dump()
invoke(Binarize) |
Beta Was this translation helpful? Give feedback.
Replies: 5 comments 15 replies
-
So cappa doesn't automatically infer dataclass annotated things are commands/subcommands, beyond the top-level class being parsed against, i.e. But i think there are a couple of problems going on here.
I think, depending on your answer to number 3, it should be possible to arrive at the expected CLI shape and behavior, just by explicitly supplying some combination of |
Beta Was this translation helpful? Give feedback.
-
Okay after reading your (very thorough, thanks!) explanation I see what you mean. I've written an example below that operates on an assumption that all bottom-level/leaf commands have arguments In the example below, I've passed a quick-and-dirty custom backend to
The example in the collapsed "details" fold below exercises Cappa in a few different ways, and also shows how Pydantic settings' CLI works with the same
I will probably expose this "special" part of my CLI with a bunch of uniform "pipeline stages" from a different entry point so that this wonky custom Details"""Pipeline stage for making ice cream."""
from __future__ import annotations
import sys
from contextlib import suppress
from json import loads
from pathlib import Path
from typing import Annotated, Any, TypeVar
from cappa.arg import Arg
from cappa.base import command, invoke, parse
from cappa.command import Command
from cappa.output import Output
from cappa.parser import backend
from pydantic import BaseModel
from pydantic_settings import BaseSettings
def main(stage: MakeIceCream | PydanticSettingsMakeIceCream): # noqa: D103
print( # noqa: T201
f"Here's your {stage.params.color.upper()} ice cream in a {stage.outs.waffle_cone.stem.upper()} waffle cone!\n"
)
class Params(BaseModel): # noqa: D101
color: str = "yellow"
flavor: str = "vanilla"
class Deps(BaseModel): # noqa: D101
freezer: Path = Path("freezer")
stand_mixer: Path = Path("stand_mixer")
class Outs(BaseModel): # noqa: D101
waffle_cone: Path = Path("waffle_cone")
bowl: Path = Path("bowl")
class MakeIceCream(BaseSettings):
"""Make ice cream."""
params: Annotated[Params, Arg(long=True)] = Params()
deps: Annotated[Deps, Arg(long=True)] = Deps()
outs: Annotated[Outs, Arg(long=True)] = Outs()
class PydanticSettingsMakeIceCream(MakeIceCream, cli_parse_args=True): ... # noqa: D101
T = TypeVar("T")
def defaults_backend(
command: Command[T],
argv: list[str],
output: Output,
prog: str,
provide_completions: bool = False,
) -> tuple[Any, Command[T], dict[str, Any]]:
"""Backend that injects defaults and makes `model_validate` the parse callable."""
parser, parsed_command, parsed_args = backend(
command=command,
argv=argv,
output=output,
prog=prog,
provide_completions=provide_completions,
)
extra_args = ["help", "completion"]
defaults: dict[str, dict[str, Any]] = {}
for arg in parsed_command.arguments:
if arg.value_name in extra_args: # pyright: ignore[reportAttributeAccessIssue]
continue
arg.parse = arg.parse.model_validate # pyright: ignore[reportAttributeAccessIssue, reportFunctionMemberAccess, reportOptionalMemberAccess]
defaults[arg.value_name] = arg.default.model_dump_json() # pyright: ignore[reportAttributeAccessIssue, reportOptionalMemberAccess]
return (
parser,
parsed_command,
{k: loads(v) for k, v in {**defaults, **parsed_args}.items()}, # pyright: ignore[reportArgumentType]
)
def main2(stage: InvokeMakeIceCream): # noqa: D103
print( # noqa: T201
f"Here's your {stage.params.color.upper()} ice cream in a {stage.outs.waffle_cone.stem.upper()} waffle cone!\n"
)
@command(invoke=main2)
class InvokeMakeIceCream(BaseModel): # noqa: D101
params: Annotated[Params, Arg(long=True)] = Params()
deps: Annotated[Deps, Arg(long=True)] = Deps()
outs: Annotated[Outs, Arg(long=True)] = Outs()
if __name__ == "__main__":
if len(sys.argv) != 1:
invoke(InvokeMakeIceCream, backend=defaults_backend)
sys.exit()
with suppress(SystemExit):
print("\nCAPPA HELP:") # noqa: T201
main(parse(MakeIceCream, backend=defaults_backend, argv=["--help"]))
with suppress(SystemExit):
print("\nCAPPA NO ARGS:") # noqa: T201
invoke(InvokeMakeIceCream, backend=defaults_backend)
sys.argv = [
"make_ice_cream.py",
"--params",
'{"color": "blue"}',
"--outs",
'{"waffle_cone": "chocolate_dipped"}',
]
with suppress(SystemExit):
print("\nCAPPA WITH CUSTOM BACKEND:") # noqa: T201
main(parse(MakeIceCream, backend=defaults_backend))
with suppress(SystemExit):
print("PYDANTIC SETTINGS:") # noqa: T201
main(PydanticSettingsMakeIceCream()) |
Beta Was this translation helpful? Give feedback.
-
And to avoid burying the lede in that other reply, I'll quote the other actionable bit here that you have mentioned...
And...
Which could make sense. The And...
Which I now think I understand after digging through the internals, which is that the structure of But the crux is that in def map_result(self, command: Command[T], prog: str, parsed_args) -> T:
kwargs = {}
for arg in self.value_arguments():
is_subcommand = isinstance(arg, Subcommand) # Side note: this var could be used below...
if arg.field_name not in parsed_args:
# handle default
else:
# get arg as parsed by the backend
if ...:
# handle other special stuff
if isinstance(arg, Subcommand): # Side note: This could be `is_subcommand` I think?
# handle subcommand
else:
# apply `Arg(parse=...)` or else default parsing
kwargs[arg.field_name] = value
return command.cmd_cls(**kwargs) |
Beta Was this translation helpful? Give feedback.
-
#150: Merged |
Beta Was this translation helpful? Give feedback.
-
The argument destructuring concept looks great! In the interim I flattened a bunch of format-related args from a Handling arg destructuring as a simple extension of parent args during command collection is a great way to handle this. Seems a cleaner approach, rather than expecting the destructured arg name be pre-pended e.g. In the docs example, if |
Beta Was this translation helpful? Give feedback.
I'm not entirely sure whether i'm fully grasping the complexities in this example or not. But if it's just a matter of making singular CLI inputs parse into arbitrary shapes, then you ought to be able to use
Arg.parse
to specify the custom mechanism required to turn that string input into the object you want.I've simplified your example into what seems like the minimal request here.