Accepting empty values
According to the Docs:
num_args=-1can be used to indicate that 0 or more (i.e. unbounded number of) arguments will be consumed, similarly resulting in a sequence-type output (even in the zero case).
Though if I do the following:
numbers: Annotated[
list[int],
cappa.Arg(
long=True,
default=[1, 2, 3],
num_args=-1,
),
]
# $ uv run my_cli --numbers
# Error: Argument '--numbers' requires at least one values, found 0
I would expect to just get an empty list, as the docs say "resulting in a sequence-type output (even in the zero case)."
argparse does have the behavior I want:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
'--numbers',
nargs='*',
type=list[int],
default=[1, 2, 3],
help='List of numbers (default: [1, 2, 3], pass flag alone for [])'
)
args = parser.parse_args()
print(f"{args.numbers=}")
# $ uv run my_cli --numbers
# args.numbers=[]
In the same vein, is there a way to specify something similar to argparse's nargs='?' parameter in cappa?
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
'--number',
type=int,
nargs='?',
default=5,
help='A number (default: 5, pass flag alone for None value)'
)
args = parser.parse_args()
print(f"{args.number=}")
# $ uv run my_cli --number
# args.number=None
Though I understand passing an arg without any values in order to produce a NoneType or empty sequence could be confusing to some CLI users. Also, thank you for all your hard work on this project, it's amazing and much appreciated!
Hi, thanks for the report!
I think I agree with your take on the first instance. I believe it is the way it is currently, to ensure that the default is hit when the arg is entirely omitted. but in this case, you're supplying the value but sans a value. In this case, the lack of a value on an unbounded number of args seems to logically follow as an empty list, and it is a distinct situation that we can handle separately from the lack of an argument at all.
On the second instance, I'm less sure. We internally use ? for the argparse backend in some cases, but it's tied to the user supplied backend, it should never result to None from lack of a value (unless the value itself is None). i feel like i find that behavior weird.
you can replicate that behavior today the below example, but idk if that feels as good to you.
from dataclasses import dataclass
from typing import Annotated, Any
import cappa
from cappa.parser import Value
def optional_value(value: Value[list[Any]]):
return None if not value.value else value.value[0]
@dataclass
class Args:
verbose: Annotated[int | None, cappa.Arg(short="-v", action=optional_value, num_args=-1)] = 4
print(cappa.parse(Args))
I believe it is the way it is currently, to ensure that the default is hit when the arg is entirely omitted
This might be a place where it's important to remind users of their behavior, and I admit that I often overlooked this in the past.
I encountered this issue while upgrading an old project, but I noticed that the MRE in a previous version seems inconsistent with the current behavior: https://github.com/DanCardin/cappa/issues/74#issuecomment-1789411965
For the second instance, see here as well:https://github.com/DanCardin/cappa/discussions/240
I investigated this issue, and this behavior change occurred in #194 (cappa >= 0.26.2).
do you have a specific mre? I have a candidate fix for the first issue locally, but in both of the above PRs, the changes I made had accompanying tests (and had the result of net better behavior for the cases they handled), so there's clearly some ambiguous behavior going on re the existing tests for a real-world CLI.
for the 2nd issue, i'm not sure whether 240 is the same thing? in this case the goal is an option which accepts both --foo and --foo bar forms, where --foo is expected to receive some default value that's distinct from the default resulting from no argument.
Let's ignore 240 first, I just want to emphasize that default values are always output by default.
I'm not sure what this MRE is meant to express.
from dataclasses import dataclass
from typing import Annotated, Any
import cappa
from cappa.parser import Value
def optional_value(value: Value[list[Any]]):
print(value)
return None if not value.value else value.value[0]
@dataclass
class Args:
verbose: Annotated[
int | None, cappa.Arg(short="-v", action=optional_value, num_args=-1)
] = 4
print(cappa.parse(Args))
# Args(verbose=4)
print(cappa.parse(Args, argv=["-v", "1"]))
# Value(value=['1'])
# Args(verbose=1)
Here is the behavior of different versions when 'num_args=-1'.
from dataclasses import dataclass
from typing import Annotated
import cappa
@dataclass
class ArgTest:
numbers: Annotated[
list[int],
cappa.Arg(
long=True,
default=[1, 2, 3],
num_args=-1,
),
]
# print(cappa.__version__) # Assumption export
# 0.30.4
print(cappa.parse(ArgTest))
# ArgTest(numbers=[1, 2, 3])
# print(cappa.parse(ArgTest, argv=['--numbers']))
# Usage: arg-test [--numbers NUMBERS ...] [-h] [--completion COMPLETION]
#
# Error: Argument '--numbers' requires at least one values, found 0
print(cappa.parse(ArgTest, argv=['--numbers', '1', '2', '3', '4']))
# ArgTest(numbers=[1, 2, 3, 4])
# print(cappa.__version__) # Assumption export
# 0.26.1
print(cappa.parse(ArgTest))
# ArgTest(numbers=[1, 2, 3])
print(cappa.parse(ArgTest, argv=['--numbers']))
# ArgTest(numbers=[])
print(cappa.parse(ArgTest, argv=['--numbers', '1', '2', '3', '4']))
# ArgTest(numbers=[1, 2, 3, 4])
Aha, so exactly this scenario. just wanted to check that it wasn't some alternate usecase, given that I didn't have a preexisting test that covered this case before.
Would either of you care to test the above branch on your real-world CLIs?
Hi~ @DanCardin
I tested branch #253, and it's working fine now.
By the way, it would be better if we could expose __version__ (this is a common practice).
uv doesn't support this yet, but hatch is known to do this:
https://hatch.pypa.io/latest/version/
https://hatch.pypa.io/latest/how-to/config/dynamic-metadata/
Feel free to open an issue about it. my initial reaction, barring there being some uv-available magic from importlib.metadata import version; version('cappa') ought to be enough for all packages and __version__ just seems vestigial. but open to being convinced.
so the remaining outstanding aspect of this issue is nargs='?' allowing foo: Annotated[int | None, Arg(...)] = 5 such that
-
test.py->Args(foo=5) -
test.py --foo->Args(foo=None) -
test.py --foo 4->Args(foo=4)
Click has some flag_value that seems to produce similar behavior, and I dont immediately see a way around requiring a new Arg option, or else Arg(num_args=NumArgs(required=False)) like
class NumArgs:
n: int = 1
required: bool = True
default: Any = None