Skip to content

Commit

Permalink
feat: Support dataclass_transform and apply dataclass decorator if su…
Browse files Browse the repository at this point in the history
…pported class not detected.
  • Loading branch information
DanCardin committed Sep 29, 2023
1 parent ae252e1 commit fbaf9d4
Show file tree
Hide file tree
Showing 12 changed files with 93 additions and 51 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ def function(example: Example):
print(example)

@cappa.command(invoke=function)
@dataclass
class Example: # identical to original class
positional_arg: str
boolean_flag: bool
Expand All @@ -84,6 +83,9 @@ class Example: # identical to original class
cappa.invoke(Example)
```

(Note the lack of the dataclass decorator. You can optionally omit or include
it, and it will be automatically inferred).

Alternatively you can make your dataclass callable, as a shorthand for an
explcit invoke function:

Expand Down Expand Up @@ -157,7 +159,6 @@ def print_cmd(print: Print):
print("printing!")

@cappa.invoke(invoke=print_cmd)
@dataclass
class Print:
loudly: bool

Expand Down
17 changes: 17 additions & 0 deletions docs/source/class_compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ be supported with relatively little effort. Today Cappa ships with adapters for
Additionally the `default` and/or `default_factory` options defined by each of
the above libraries is used to infer CLI defaults.

## PEP681

You can opt to `@cappa.command(...)` with or without the double-decorator.

```python
@cappa.command(...)
@dataclass
```

`@cappa.command(...)` works without the `@dataclass` decorator at runtime,
because it detects whether the class is one of the supported types (dataclasses,
pydantic, attrs), and adds `@dataclass` to the declared class automatically, if
one is not detected.

So long as you use a a [PEP681 compliant](https://peps.python.org/pep-0681/)
type checker (e.g. pyright, Mypy>=1.2).

## Metadata

Finally, `dataclasses` and `attrs` both allow a `metadata`. You can optionally
Expand Down
3 changes: 0 additions & 3 deletions docs/source/invoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ be invoked for your command.
print(example.foo)

@cappa.command(invoke=function)
@dataclass
class Example:
foo: int
```
Expand All @@ -49,7 +48,6 @@ be invoked for your command.
```python
# example.py
@cappa.command(invoke='foo.bar.function')
@dataclass
class Example:
foo: int

Expand Down Expand Up @@ -96,7 +94,6 @@ the string `package.module.submodule.foo`.

```python
@cappa.command(invoke='package.module.submodule.foo')
@dataclass
class Example:
...
```
Expand Down
1 change: 0 additions & 1 deletion docs/source/parse_vs_invoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ def example(ex: Example):


@cappa.command(invoke=example)
@dataclass
class Example
... # your original example body

Expand Down
1 change: 0 additions & 1 deletion docs/source/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def fn(config: Annotated[dict, cappa.Dep(config)]):
print(config)

@cappa.command(invoke=fn)
@dataclass
class CLI:
name: str
```
Expand Down
4 changes: 1 addition & 3 deletions src/cappa/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
from cappa.arg import Arg, ArgAction
from cappa.base import invoke, parse
from cappa.base import command, invoke, parse
from cappa.command import Command
from cappa.env import Env
from cappa.invoke import Dep
from cappa.output import Exit, print
from cappa.subcommand import Subcmd, Subcommand, Subcommands

command = Command.wrap

__all__ = [
"Arg",
"ArgAction",
Expand Down
45 changes: 45 additions & 0 deletions src/cappa/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from __future__ import annotations

import dataclasses
import sys
import typing

from typing_extensions import dataclass_transform

from cappa.arg import Arg
from cappa.class_inspect import detect
from cappa.command import Command
from cappa.invoke import invoke_callable

Expand Down Expand Up @@ -107,3 +111,44 @@ def invoke(
)

return invoke_callable(command, parsed_command, instance, deps=deps)


@dataclass_transform()
def command(
*,
name: str | None = None,
help: str | None = None,
description: str | None = None,
invoke: typing.Callable | str | None = None,
):
"""Register a cappa CLI command/subcomment.
Args:
name: The name of the command. If omitted, the name of the command
will be the name of the `cls`, converted to dash-case.
help: Optional help text. If omitted, the `cls` docstring will be parsed,
and the headline section will be used to document the command
itself, and the arguments section will become the default help text for
any params/options.
description: Optional extended help text. If omitted, the `cls` docstring will
be parsed, and the extended long description section will be used.
invoke: Optional command to be called in the event parsing is successful.
In the case of subcommands, it will only call the parsed/selected
function to invoke.
"""

def wrapper(_decorated_cls):
if not detect(_decorated_cls):
_decorated_cls = dataclasses.dataclass(_decorated_cls)

instance = Command(
cmd_cls=_decorated_cls,
invoke=invoke,
name=name,
help=help,
description=description,
)
_decorated_cls.__cappa__ = instance
return _decorated_cls

return wrapper
8 changes: 8 additions & 0 deletions src/cappa/class_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@

__all__ = [
"fields",
"detect",
]


def detect(cls: type) -> bool:
try:
return bool(ClassTypes.from_cls(cls))
except ValueError:
return False


class ClassTypes(Enum):
dataclass = "dataclass"
pydantic = "pydantic"
Expand Down
38 changes: 0 additions & 38 deletions src/cappa/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,44 +57,6 @@ class Command(typing.Generic[T]):
description: str | None = None
invoke: Callable | str | None = None

@classmethod
def wrap(
cls,
*,
name: str | None = None,
help: str | None = None,
description: str | None = None,
invoke: Callable | str | None = None,
):
"""Register a cappa CLI command/subcomment.
Args:
name: The name of the command. If omitted, the name of the command
will be the name of the `cls`, converted to dash-case.
help: Optional help text. If omitted, the `cls` docstring will be parsed,
and the headline section will be used to document the command
itself, and the arguments section will become the default help text for
any params/options.
description: Optional extended help text. If omitted, the `cls` docstring will
be parsed, and the extended long description section will be used.
invoke: Optional command to be called in the event parsing is successful.
In the case of subcommands, it will only call the parsed/selected
function to invoke.
"""

def wrapper(_decorated_cls):
instance = cls(
cmd_cls=_decorated_cls,
invoke=invoke,
name=name,
help=help,
description=description,
)
_decorated_cls.__cappa__ = instance
return _decorated_cls

return wrapper

@classmethod
def get(cls, obj: typing.Type[T]) -> Self:
if isinstance(obj, cls):
Expand Down
4 changes: 2 additions & 2 deletions src/cappa/subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import dataclasses
import typing

from typing_extensions import Self
from typing_extensions import Annotated, Self, TypeAlias
from typing_inspect import is_optional_type, is_union_type

from cappa.class_inspect import Field, extract_dataclass_metadata
Expand Down Expand Up @@ -83,4 +83,4 @@ def map_result(self, parsed_args):
return option.map_result(option, parsed_args)


Subcmd: typing.TypeAlias = typing.Annotated[T, Subcommand]
Subcmd: TypeAlias = Annotated[T, Subcommand]
2 changes: 1 addition & 1 deletion tests/invoke/test_callable_dataclass.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Annotated

import cappa
from typing_extensions import Annotated

from tests.utils import invoke

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

import cappa

from tests.utils import parse


@cappa.command(name="sub")
class Example:
bar: int
name: str


def test_invoke_top_level_command():
result = parse(Example, "4", "foo")
assert result == Example(bar=4, name="foo")

0 comments on commit fbaf9d4

Please sign in to comment.