Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ class NestedCommand:
class SubCommand(FlattenedNested):
# Options / flags
option: str = cb.option("-o", "--option", help="A simple option")
flag: bool = cb.flag("-f", "--flag", help="A boolean flag")
flag: bool = cb.flag("-f", "--flag", help="A boolean flag", stackable=True)
flag2: bool = cb.flag("-F", "--flag2", help="Another boolean flag", stackable=True)

# File / directory inputs
file: str = cb.file(
Expand Down
3 changes: 3 additions & 0 deletions src/cranberry/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class annotation when ``None``.
long: str | None = None
help: str = ""
default: Any = field(default_factory=lambda: None)
stackable: bool = False # for flags only
type: Any = None # filled in by registry from the class annotation
required: bool = (
False # for args, this is just a convenience property based on default
Expand Down Expand Up @@ -120,6 +121,7 @@ def flag(
*,
help: str = "",
default: bool = False,
stackable: bool = False,
) -> Any:
"""Declare a boolean flag that stores *True* when present (``-f`` / ``--flag``)."""
return FieldSpec(
Expand All @@ -128,6 +130,7 @@ def flag(
long=long,
help=help,
default=default,
stackable=stackable,
type=bool,
)

Expand Down
52 changes: 49 additions & 3 deletions src/cranberry/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,31 @@ def _parse_leaf_command(
i += 2
continue

# Combined short flags allowed for stackable boolean flags (e.g. -fs -> -f and -s) - only if all are valid flags.
if token.startswith("-") and not token.startswith("--") and len(token) > 2:
# Attempt to decompose into multiple short flags
j = 1
short_flags: list[str] = []
ok = True
while j < len(token):
short = "-" + token[j]
entry = flag_map.get(short)
if (
entry is None
or entry[1].kind != "flag"
or entry[1].stackable is False
):
ok = False
break
short_flags.append(short)
j += 1
if ok and short_flags:
for short in short_flags:
attr, spec = flag_map[short]
values[attr] = True
i += 1
continue

if token.startswith("-"):
raise CranberryParseError(f"Unknown flag: {token!r}")

Expand Down Expand Up @@ -298,9 +323,30 @@ def parse_args(argv: list[str] | None = None) -> ParseContext:
else:
global_values[attr] = _coerce(argv[i + 1], spec, attr)
i += 2
else:
stripped.append(t)
i += 1
continue

# Combined short flags for globals (e.g., -fF -> -f and -F) - only allowed for boolean flags
if t.startswith("-") and not t.startswith("--") and len(t) > 2:
j = 1
short_flags = []
ok = True
while j < len(t):
short = "-" + t[j]
entry = global_map.get(short)
if entry is None or entry[1].kind != "flag":
ok = False
break
short_flags.append(short)
j += 1
if ok and short_flags:
for short in short_flags:
attr, spec = global_map[short]
global_values[attr] = True
i += 1
continue

stripped.append(t)
i += 1

for n, s in global_fields.items():
global_values.setdefault(n, s.default)
Expand Down
37 changes: 37 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,43 @@ def test_version_short_flag(self, capsys):
cb.parse_args(["-V"])


# ---------------------------------------------------------------------------
# Combined short flags
# ---------------------------------------------------------------------------
class TestParseStackableFlags:
def test_combined_short_flags_fs(self, capsys):
@cb.command("c")
class C:
first: bool = cb.flag("-f", "--flag", stackable=True)
second: bool = cb.flag("-s", "--second", stackable=True)

make_app(C)
ctx = cb.parse_args(["c", "-fs"])
assert ctx.command.first is True
assert ctx.command.second is True

def test_combined_short_flags_sf(self, capsys):
@cb.command("c2")
class C2:
first: bool = cb.flag("-f", "--flag", stackable=True)
second: bool = cb.flag("-s", "--second", stackable=True)

make_app(C2)
ctx = cb.parse_args(["c2", "-sf"])
assert ctx.command.first is True
assert ctx.command.second is True

def test_non_stackable_flag_combined_raises(self, capsys):
@cb.command("c3")
class C3:
first: bool = cb.flag("-f", "--flag", stackable=False)
second: bool = cb.flag("-s", "--second", stackable=True)

make_app(C3)
with pytest.raises(CranberryParseError, match="Unknown flag: '-fs'"):
cb.parse_args(["c3", "-fs"])


# ---------------------------------------------------------------------------
# ParseContext repr and globals
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading