Skip to content

Commit

Permalink
Merge pull request #111 from DanCardin/dc/deprecated
Browse files Browse the repository at this point in the history
feat: Add deprecated option to command/arg.
  • Loading branch information
DanCardin committed Apr 11, 2024
2 parents cd24bfc + 9811668 commit 1201cbe
Show file tree
Hide file tree
Showing 9 changed files with 246 additions and 10 deletions.
15 changes: 15 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ jobs:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
pydantic-version: ["1.0", "2.0"]

# No need to matrix test pydantic versions. But the matrix syntax still
# keeps the file DRY.
exclude:
- python-version: '3.8'
pydantic-version: '1.0'
- python-version: '3.9'
pydantic-version: '1.0'
- python-version: '3.11'
pydantic-version: '1.0'
- python-version: '3.12'
pydantic-version: '1.0'
- python-version: '3.13-dev'
pydantic-version: '1.0'

steps:
- uses: actions/checkout@v3
- name: Set up Python
Expand Down Expand Up @@ -51,6 +65,7 @@ jobs:

- name: Run Linters
run: poetry run make lint
if: ${{ !endsWith(matrix.python-version, '-dev') }}

- name: Run tests
run: poetry run make test
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.18.0"
version = "0.18.1"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down
13 changes: 7 additions & 6 deletions src/cappa/arg.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Define the external facing argument definition types provided by a user."""

from __future__ import annotations

import dataclasses
Expand Down Expand Up @@ -105,14 +106,15 @@ class Arg(typing.Generic[T]):
choices: Generally automatically inferred from the data type. This allows to
override the default.
completion: Used to provide custom completions. If specified, should be a function
which acccepts a partial string value and returns a list of
which accepts a partial string value and returns a list of
[cappa.Completion](cappa.Completion) objects.
required: Defaults to automatically inferring requiredness, based on whether the
class's value has a default. By setting this, you can force a particular value.
field_name: The name of the class field to populate with this arg. In most usecases,
this field should be left unspecified and automatically inferred.
deprecated: If supplied, the argument will be marked as deprecated. If given `True`,
a default message will be generated, otherwise a supplied string will be
used as the deprecation message.
"""

value_name: str | MISSING = missing
Expand All @@ -130,10 +132,9 @@ class Arg(typing.Generic[T]):
num_args: int | None = None
choices: list[str] | None = None
completion: Callable[..., list[Completion]] | None = None

required: bool | None = None

field_name: str | MISSING = missing
deprecated: bool | str = False

annotations: list[type] = dataclasses.field(default_factory=list)

Expand All @@ -157,7 +158,7 @@ def collect(
if object_annotation.doc:
fallback_help = object_annotation.doc

# Dataclass field metatdata takes precedence if it exists.
# Dataclass field metadata takes precedence if it exists.
field_metadata = extract_dataclass_metadata(field)
assert not isinstance(field_metadata, Subcommand)

Expand Down
14 changes: 13 additions & 1 deletion src/cappa/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ class Nestedspace(argparse.Namespace):
"""Write each . separated section as a nested `Nestedspace` instance.
By default, argparse write everything to a flat namespace so there's no
obvious way to distinguish between mulitple unrelated subcommands once
obvious way to distinguish between multiple unrelated subcommands once
once has been chosen.
"""

Expand Down Expand Up @@ -265,6 +265,8 @@ def add_argument(
if arg.choices:
kwargs["choices"] = arg.choices

deprecated_kwarg = add_deprecated_kwarg(arg)
kwargs.update(deprecated_kwarg)
kwargs.update(extra_kwargs)

parser.add_argument(*names, **kwargs)
Expand All @@ -285,6 +287,8 @@ def add_subcommands(
)

for name, subcommand in subcommands.options.items():
deprecated_kwarg = add_deprecated_kwarg(subcommand)

nested_dest_prefix = f"{dest_prefix}{subcommand_dest}."
subparser = subparsers.add_parser(
name=subcommand.real_name(),
Expand All @@ -295,6 +299,7 @@ def add_subcommands(
command=subcommand, # type: ignore
output=output,
prog=f"{parser.prog} {subcommand.real_name()}",
**deprecated_kwarg,
)
subparser.set_defaults(
__command__=subcommand, **{nested_dest_prefix + "__name__": name}
Expand Down Expand Up @@ -344,3 +349,10 @@ def get_action(arg: Arg) -> argparse.Action | type[argparse.Action] | str:

action = typing.cast(Callable, action)
return custom_action(arg, action)


def add_deprecated_kwarg(arg: Arg | Command) -> dict[str, typing.Any]:
if sys.version_info < (3, 13) or not arg.deprecated:
return {}

return {"deprecated": arg.deprecated}
9 changes: 7 additions & 2 deletions src/cappa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def invoke(
Arguments:
obj: A class which can represent a CLI command chain.
deps: Optional extra depdnencies to load ahead of invoke processing. These
deps are evaulated in order and unconditionally.
deps are evaluated in order and unconditionally.
argv: Defaults to the process argv. This command is generally only
necessary when testing.
backend: A function used to perform the underlying parsing and return a raw
Expand Down Expand Up @@ -165,7 +165,7 @@ async def invoke_async(
Arguments:
obj: A class which can represent a CLI command chain.
deps: Optional extra depdnencies to load ahead of invoke processing. These
deps are evaulated in order and unconditionally.
deps are evaluated in order and unconditionally.
argv: Defaults to the process argv. This command is generally only
necessary when testing.
backend: A function used to perform the underlying parsing and return a raw
Expand Down Expand Up @@ -250,6 +250,7 @@ def command(
hidden: bool = False,
default_short: bool = False,
default_long: bool = False,
deprecated: bool = False,
):
"""Register a cappa CLI command/subcomment.
Expand All @@ -271,6 +272,9 @@ def command(
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.
deprecated: If supplied, the argument will be marked as deprecated. If given `True`,
a default message will be generated, otherwise a supplied string will be
used as the deprecation message.
"""

def wrapper(_decorated_cls):
Expand All @@ -286,6 +290,7 @@ def wrapper(_decorated_cls):
hidden=hidden,
default_short=default_short,
default_long=default_long,
deprecated=deprecated,
)
_decorated_cls.__cappa__ = instance
return _decorated_cls
Expand Down
4 changes: 4 additions & 0 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class Command(typing.Generic[T]):
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.
deprecated: If supplied, the argument will be marked as deprecated. If given `True`,
a default message will be generated, otherwise a supplied string will be
used as the deprecation message.
"""

cmd_cls: type[T]
Expand All @@ -75,6 +78,7 @@ class Command(typing.Generic[T]):
hidden: bool = False
default_short: bool = False
default_long: bool = False
deprecated: bool | str = False

_collected: bool = False

Expand Down
28 changes: 28 additions & 0 deletions src/cappa/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ def consume_subcommand(context: ParseContext, arg: Subcommand) -> typing.Any:
)

command = arg.options[value.raw]
check_deprecated(context, command)

context.command_stack.append(command)

nested_context = ParseContext.from_command(
Expand Down Expand Up @@ -571,6 +573,32 @@ def consume_arg(
kwargs = fullfill_deps(action_handler, fullfilled_deps)
context.result[arg.field_name] = action_handler(**kwargs)

check_deprecated(context, arg, option)


def check_deprecated(
context: ParseContext, arg: Arg | Command, option: RawOption | None = None
) -> None:
if not arg.deprecated:
return

if option:
kind = "Option"
name = option.name
else:
if isinstance(arg, Command):
kind = "Command"
name = arg.real_name()
else:
kind = "Argument"
name = arg.names_str("/")

message = f"{kind} `{name}` is deprecated"
if isinstance(arg.deprecated, str):
message += f": {arg.deprecated}"

context.output.error(message)


@dataclasses.dataclass
class Value(typing.Generic[T]):
Expand Down
109 changes: 109 additions & 0 deletions tests/arg/test_deprecated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from __future__ import annotations

import sys
import textwrap
from dataclasses import dataclass

import cappa
import pytest
from typing_extensions import Annotated

from tests.utils import parse


def test_native_backend(capsys):
@dataclass
class ArgTest:
arg1: Annotated[str, cappa.Arg(deprecated=True)] = "default"
arg2: Annotated[
str, cappa.Arg(short="a", long="--aaaa", deprecated=True)
] = "default"
arg3: Annotated[
str, cappa.Arg(short="b", deprecated="Use something else")
] = "default"

result = parse(ArgTest)
assert result.arg1 == "default"
assert result.arg2 == "default"
assert result.arg3 == "default"
err = capsys.readouterr().err
assert err == ""

result = parse(ArgTest, "a")
assert result.arg1 == "a"
err = capsys.readouterr().err
assert err == "Error: Argument `arg1` is deprecated\n"

result = parse(ArgTest, "-a", "1")
assert result.arg2 == "1"
err = capsys.readouterr().err
assert err == "Error: Option `-a` is deprecated\n"

result = parse(ArgTest, "-b", "1")
assert result.arg3 == "1"
err = capsys.readouterr().err
assert err == "Error: Option `-b` is deprecated: Use something else\n"


@pytest.mark.skipif(sys.version_info >= (3, 13), reason="requires python3.13 or higher")
def test_argparse_le_313(capsys):
"""Below 3.13, this option has no effect."""

@dataclass
class ArgTest:
arg1: Annotated[str, cappa.Arg(deprecated=True)] = "default"
arg2: Annotated[
str, cappa.Arg(short="a", long="--aaaa", deprecated=True)
] = "default"
arg3: Annotated[
str, cappa.Arg(short="b", deprecated="Use something else")
] = "default"

result = parse(ArgTest, backend=cappa.argparse.backend)
assert result.arg1 == "default"
assert result.arg2 == "default"
assert result.arg3 == "default"
err = capsys.readouterr().err
assert err == ""

result = parse(ArgTest, "1", "-a", "1", "-b", "1", backend=cappa.argparse.backend)
assert result.arg1 == "1"
assert result.arg1 == "1"
assert result.arg1 == "1"
err = capsys.readouterr().err
assert err == ""


@pytest.mark.skipif(
sys.version_info < (3, 13), reason="Below 3.13, the behavior is different"
)
def test_argparse_ge_313(capsys):
@dataclass
class ArgTest:
arg1: Annotated[str, cappa.Arg(deprecated=True)] = "default"
arg2: Annotated[
str, cappa.Arg(short="a", long="--aaaa", deprecated=True)
] = "default"
arg3: Annotated[
str, cappa.Arg(short="b", deprecated="Use something else")
] = "default"

result = parse(ArgTest, backend=cappa.argparse.backend)
assert result.arg1 == "default"
assert result.arg2 == "default"
assert result.arg3 == "default"
err = capsys.readouterr().err
assert err == ""

result = parse(ArgTest, "1", "-a", "1", "-b", "1", backend=cappa.argparse.backend)
assert result.arg1 == "1"
assert result.arg1 == "1"
assert result.arg1 == "1"
err = capsys.readouterr().err
assert err == textwrap.dedent(
"""\
warning: argument 'arg1' is deprecated
warning: option '-a' is deprecated
warning: option '-b' is deprecated
"""
)

0 comments on commit 1201cbe

Please sign in to comment.