Skip to content

Commit

Permalink
Merge pull request #110 from DanCardin/dc/default_long_short
Browse files Browse the repository at this point in the history
feat: Add `default_short=False` and `default_long=False` options to command for ease of definining option-heavy commands.
  • Loading branch information
DanCardin authored Mar 27, 2024
2 parents 987a8eb + 9bfbdf9 commit cd24bfc
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 26 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.18

### 0.18.0

- feat: Add `default_short=False` and `default_long=False` options to command
for ease of definining option-heavy commands.

## 0.17

### 0.17.3
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cappa"
version = "0.17.3"
version = "0.18.0"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
45 changes: 31 additions & 14 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ class Arg(typing.Generic[T]):
"""

value_name: str | MISSING = missing
short: bool | str | list[str] = False
long: bool | str | list[str] = False
short: bool | str | list[str] | None = False
long: bool | str | list[str] | None = False
count: bool = False
default: T | None | MISSING = missing
help: str | None = None
Expand All @@ -139,7 +139,12 @@ class Arg(typing.Generic[T]):

@classmethod
def collect(
cls, field: Field, type_hint: type, fallback_help: str | None = None
cls,
field: Field,
type_hint: type,
fallback_help: str | None = None,
default_short: bool = False,
default_long: bool = False,
) -> Arg:
object_annotation = find_type_annotation(type_hint, cls)
annotation = object_annotation.annotation
Expand Down Expand Up @@ -168,7 +173,12 @@ def collect(
default=default,
annotations=object_annotation.other_annotations,
)
return arg.normalize(annotation, fallback_help=fallback_help)
return arg.normalize(
annotation,
fallback_help=fallback_help,
default_short=default_short,
default_long=default_long,
)

def normalize(
self,
Expand All @@ -177,6 +187,8 @@ def normalize(
action: ArgAction | Callable | None = None,
default: typing.Any = missing,
field_name: str | None = None,
default_short: bool = False,
default_long: bool = False,
) -> Arg:
origin = typing.get_origin(annotation) or annotation
type_args = typing.get_args(annotation)
Expand All @@ -185,8 +197,8 @@ def normalize(
default = default if default is not missing else self.default

verify_type_compatibility(self, field_name, annotation, origin, type_args)
short = infer_short(self, field_name)
long = infer_long(self, origin, field_name)
short = infer_short(self, field_name, default_short)
long = infer_long(self, origin, field_name, default_long)
choices = infer_choices(self, origin, type_args)
action = action or infer_action(
self, annotation, origin, type_args, long, default
Expand Down Expand Up @@ -342,24 +354,29 @@ def infer_required(arg: Arg, annotation: type, default: typing.Any | MISSING):
return False


def infer_short(arg: Arg, name: str) -> list[str] | typing.Literal[False]:
if not arg.short:
def infer_short(
arg: Arg, name: str, default: bool = False
) -> list[str] | typing.Literal[False]:
short = arg.short or default
if not short:
return False

if isinstance(arg.short, bool):
if isinstance(short, bool):
short_name = name[0]
return [f"-{short_name}"]

if isinstance(arg.short, str):
short = arg.short.split("/")
if isinstance(short, str):
short = short.split("/")
else:
short = arg.short
short = short

return [item if item.startswith("-") else f"-{item}" for item in short]


def infer_long(arg: Arg, origin: type, name: str) -> list[str] | typing.Literal[False]:
long = arg.long
def infer_long(
arg: Arg, origin: type, name: str, default: bool
) -> list[str] | typing.Literal[False]:
long = arg.long or default

if not long:
# bools get automatically coerced into flags, otherwise stay off.
Expand Down
8 changes: 8 additions & 0 deletions src/cappa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ def command(
description: str | None = None,
invoke: typing.Callable | str | None = None,
hidden: bool = False,
default_short: bool = False,
default_long: bool = False,
):
"""Register a cappa CLI command/subcomment.
Expand All @@ -265,6 +267,10 @@ def command(
function to invoke.
hidden: If `True`, the command will not be included in the help output.
This option is only relevant to subcommands.
default_short: If `True`, all arguments will be treated as though annotated
with `Annotated[T, Arg(short=True)]`, unless otherwise annotated.
default_long: If `True`, all arguments will be treated as though annotated
with `Annotated[T, Arg(long=True)]`, unless otherwise annotated.
"""

def wrapper(_decorated_cls):
Expand All @@ -278,6 +284,8 @@ def wrapper(_decorated_cls):
help=help,
description=description,
hidden=hidden,
default_short=default_short,
default_long=default_long,
)
_decorated_cls.__cappa__ = instance
return _decorated_cls
Expand Down
28 changes: 25 additions & 3 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ class CommandArgs(typing.TypedDict, total=False):
help: str | None
description: str | None
invoke: Callable | str | None

hidden: bool
default_short: bool
default_long: bool


@dataclasses.dataclass
Expand All @@ -56,16 +59,23 @@ class Command(typing.Generic[T]):
and the referenced function invoked.
hidden: If `True`, the command will not be included in the help output.
This option is only relevant to subcommands.
default_short: If `True`, all arguments will be treated as though annotated
with `Annotated[T, Arg(short=True)]`, unless otherwise annotated.
default_true: If `True`, all arguments will be treated as though annotated
with `Annotated[T, Arg(long=True)]`, unless otherwise annotated.
"""

cmd_cls: type[T]
arguments: list[Arg | Subcommand] = dataclasses.field(default_factory=list)
name: str | None = None
help: str | None = None
description: str | None = None
hidden: bool = False
invoke: Callable | str | None = None

hidden: bool = False
default_short: bool = False
default_long: bool = False

_collected: bool = False

@classmethod
Expand Down Expand Up @@ -115,7 +125,13 @@ def collect(cls, command: Command[T]) -> Command[T]:

if command.arguments:
arguments: list[Arg | Subcommand] = [
a.normalize() for a in command.arguments
a.normalize(
default_short=command.default_short,
default_long=command.default_long,
)
if isinstance(a, Arg)
else a.normalize()
for a in command.arguments
]
else:
fields = class_inspect.fields(command.cmd_cls)
Expand All @@ -131,7 +147,13 @@ def collect(cls, command: Command[T]) -> Command[T]:
if maybe_subcommand:
arguments.append(maybe_subcommand)
else:
arg_def: Arg = Arg.collect(field, type_hint, fallback_help=arg_help)
arg_def: Arg = Arg.collect(
field,
type_hint,
fallback_help=arg_help,
default_short=command.default_short,
default_long=command.default_long,
)
arguments.append(arg_def)

kwargs["arguments"] = arguments
Expand Down
14 changes: 8 additions & 6 deletions src/cappa/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ def collect_options(command: Command) -> tuple[dict[str, Arg], set[str]]:
unique_names.add(arg.field_name)
result[arg.field_name] = arg

assert arg.short is not True
for short in arg.short or []:
result[short] = arg
for opts in (arg.short, arg.long):
if not opts:
continue

assert arg.long is not True
for long in arg.long or []:
result[long] = arg
for key in typing.cast(typing.List[str], opts):
if key in result:
raise ValueError(f"Conflicting option string: {key}")

result[key] = arg

return result, unique_names

Expand Down
49 changes: 49 additions & 0 deletions tests/arg/test_default_long.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_valid(backend):
@cappa.command(default_long=True)
@dataclass
class Command:
foo: int = 4
bar: int = 5

test = parse(Command, backend=backend)
assert test == Command(4, 5)

test = parse(Command, "--foo", "1", "--bar", "2", backend=backend)
assert test == Command(1, 2)


@backends
def test_override(backend):
@cappa.command(default_long=True)
@dataclass
class Command:
foo: int
far: Annotated[int, cappa.Arg(long="--kar")]

test = parse(Command, "--foo", "1", "--kar", "2", backend=backend)
assert test == Command(1, 2)


@backends
def test_invalid(backend):
@cappa.command(default_long=True)
@dataclass
class Command:
foo: int
far: Annotated[int, cappa.Arg(long="--foo")]

with pytest.raises(Exception) as e:
parse(Command, backend=backend)
assert "conflicting option string: --f" in str(e.value).lower()
49 changes: 49 additions & 0 deletions tests/arg/test_default_short.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import backends, parse


@backends
def test_valid(backend):
@cappa.command(default_short=True)
@dataclass
class Command:
foo: int = 4
bar: int = 5

test = parse(Command, backend=backend)
assert test == Command(4, 5)

test = parse(Command, "-f", "1", "-b", "2", backend=backend)
assert test == Command(1, 2)


@backends
def test_override(backend):
@cappa.command(default_short=True)
@dataclass
class Command:
foo: int
far: Annotated[int, cappa.Arg(short="-k")]

test = parse(Command, "-f", "1", "-k", "2", backend=backend)
assert test == Command(1, 2)


@backends
def test_invalid(backend):
@cappa.command(default_short=True)
@dataclass
class Command:
foo: int
far: int

with pytest.raises(Exception) as e:
parse(Command, backend=backend)
assert "conflicting option string: -f" in str(e.value).lower()
4 changes: 2 additions & 2 deletions tests/help/test_line_wrapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class Args:
e: Annotated[str, cappa.Arg(short=True)]
f: Annotated[str, cappa.Arg(short=True)]
g: Annotated[str, cappa.Arg(short=True)]
h: Annotated[str, cappa.Arg(short=True)]
i: Annotated[str, cappa.Arg(short=True)]

columns = 80
env = {
Expand All @@ -38,6 +38,6 @@ class Args:
out = capsys.readouterr().out
plain, *_ = Text.from_ansi(out).plain.splitlines()

expected = "Usage: args -a A -b B -c C -d D -e E -f F -g G -h H [-h] [--completion"
expected = "Usage: args -a A -b B -c C -d D -e E -f F -g G -i I [-h] [--completion"
assert len(expected) < columns
assert plain == expected

0 comments on commit cd24bfc

Please sign in to comment.