Skip to content

Commit

Permalink
Merge pull request #114 from DanCardin/dc/msgspec
Browse files Browse the repository at this point in the history
feat: Support msgspec defined classes as source classes for cappa.
  • Loading branch information
DanCardin committed Apr 29, 2024
2 parents 1201cbe + 64a5d90 commit 98a933d
Show file tree
Hide file tree
Showing 9 changed files with 420 additions and 285 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
# Changelog

## 0.19

### 0.19.0

- feat: Add support for `msgspec` based class definitions.

## 0.18

### 0.18.1

- feat: Add deprecated option to command/arg.

### 0.18.0

- feat: Add `default_short=False` and `default_long=False` options to command
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Comparison vs existing libraries.](https://cappa.readthedocs.io/en/latest/comparison.html).
- [Annotation inference details](https://cappa.readthedocs.io/en/latest/annotation.html)
- ["invoke" (click-like) details](https://cappa.readthedocs.io/en/latest/invoke.html)
- [Class compatibility (dataclasses/pydantic/etc)](https://cappa.readthedocs.io/en/latest/class_compatibility.html)

Cappa is a declarative command line parsing library, taking much of its
inspiration from the "Derive" API from the
Expand Down
10 changes: 6 additions & 4 deletions docs/source/class_compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ All of the documentation uses `dataclasses` specifically, because it is built
into the standard library since python 3.7.

With that said, any dataclass-like class description pattern should be able to
be supported with relatively little effort. Today Cappa ships with adapters for
[dataclasses](https://docs.python.org/3/library/dataclasses.html),
[Pydantic](https://pydantic-docs.helpmanual.io/), and
[attrs](https://www.attrs.org).
be supported with relatively little effort. Today Cappa ships with adapters for:

- [dataclasses](https://docs.python.org/3/library/dataclasses.html),
- [Pydantic](https://pydantic-docs.helpmanual.io/) (v1/v2)
- [attrs](https://www.attrs.org)
- [msgspec](jcristharif.com/msgspec)

Additionally the `default` and/or `default_factory` options defined by each of
the above libraries is used to infer CLI defaults.
Expand Down
444 changes: 248 additions & 196 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cappa"
version = "0.18.1"
version = "0.19.0"
description = "Declarative CLI argument parser."

repository = "https://github.com/dancardin/cappa"
Expand Down Expand Up @@ -40,11 +40,12 @@ docstring = ["docstring-parser"]
[tool.poetry.group.dev.dependencies]
attrs = "*"
coverage = "^7.3.0"
msgspec = "*"
mypy = ">=1.0"
pydantic = "*"
pytest = "^7.4.0"
ruff = "^0.2.2"
docutils = "*"
docutils = "0.20.0"
typing-extensions = ">=4.8.0"
types-docutils = "^0.20.0.3"

Expand Down
189 changes: 109 additions & 80 deletions src/cappa/class_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,51 +21,7 @@


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


class ClassTypes(Enum):
attrs = "attrs"
dataclass = "dataclass"
pydantic_v1 = "pydantic_v1"
pydantic_v2 = "pydantic_v2"
pydantic_v2_dataclass = "pydantic_v2_dataclass"

@classmethod
def from_cls(cls, obj: type) -> ClassTypes:
if hasattr(obj, "__pydantic_fields__"):
return cls.pydantic_v2_dataclass

if dataclasses.is_dataclass(obj):
return cls.dataclass

try:
import pydantic
except ImportError: # pragma: no cover
pass
else:
try:
is_base_model = isinstance(obj, type) and issubclass(
obj, pydantic.BaseModel
)
except TypeError: # pragma: no cover
is_base_model = False

if is_base_model:
if pydantic.__version__.startswith("1."):
return cls.pydantic_v1
return cls.pydantic_v2

if hasattr(obj, "__attrs_attrs__"):
return cls.attrs

raise ValueError(
f"'{obj.__qualname__}' is not a currently supported kind of class. "
"Must be one of: dataclass, pydantic, or attrs class."
)
return bool(ClassTypes.from_cls(cls))


@dataclasses.dataclass
Expand All @@ -76,8 +32,11 @@ class Field:
default_factory: typing.Any | MISSING = missing
metadata: dict = dataclasses.field(default_factory=dict)


@dataclasses.dataclass
class DataclassField(Field):
@classmethod
def from_dataclass(cls, typ: type) -> list[Self]:
def collect(cls, typ: type) -> list[Self]:
fields = []
for f in typ.__dataclass_fields__.values(): # type: ignore
field = cls(
Expand All @@ -92,8 +51,58 @@ def from_dataclass(cls, typ: type) -> list[Self]:
fields.append(field)
return fields


@dataclasses.dataclass
class AttrsField(Field):
@classmethod
def collect(cls, typ: type) -> list[Self]:
fields = []
for f in typ.__attrs_attrs__: # type: ignore
if hasattr(f.default, "factory"):
default = None
default_factory = f.default.factory
else:
default = f.default
default_factory = None
field = cls(
name=f.name,
annotation=f.type,
default=default or missing,
default_factory=default_factory or missing,
metadata=f.metadata,
)
fields.append(field)
return fields


@dataclasses.dataclass
class MsgspecField(Field):
@classmethod
def from_pydantic_v1(cls, typ) -> list[Self]:
def collect(cls, typ: type) -> list[Self]:
import msgspec

fields = []
for f in msgspec.structs.fields(typ):
default = f.default if f.default is not msgspec.NODEFAULT else missing
default_factory = (
f.default_factory
if f.default_factory is not msgspec.NODEFAULT
else missing
)
field = cls(
name=f.name,
annotation=f.type,
default=default,
default_factory=default_factory,
)
fields.append(field)
return fields


@dataclasses.dataclass
class PydanticV1Field(Field):
@classmethod
def collect(cls, typ) -> list[Self]:
fields = []
type_hints = get_type_hints(typ, include_extras=True)
for name, f in typ.__fields__.items():
Expand All @@ -110,8 +119,11 @@ def from_pydantic_v1(cls, typ) -> list[Self]:
fields.append(field)
return fields


@dataclasses.dataclass
class PydanticV2Field(Field):
@classmethod
def from_pydantic_v2(cls, typ: type) -> list[Self]:
def collect(cls, typ: type) -> list[Self]:
fields = []
for name, f in typ.model_fields.items(): # type: ignore
field = cls(
Expand All @@ -125,8 +137,11 @@ def from_pydantic_v2(cls, typ: type) -> list[Self]:
fields.append(field)
return fields


@dataclasses.dataclass
class PydanticV2DataclassField(Field):
@classmethod
def from_pydantic_v2_dataclass(cls, typ: type) -> list[Self]:
def collect(cls, typ: type) -> list[Self]:
fields = []
for name, f in typ.__pydantic_fields__.items(): # type: ignore
field = cls(
Expand All @@ -138,45 +153,59 @@ def from_pydantic_v2_dataclass(cls, typ: type) -> list[Self]:
fields.append(field)
return fields

@classmethod
def from_attrs(cls, typ: type) -> list[Self]:
fields = []
for f in typ.__attrs_attrs__: # type: ignore
if hasattr(f.default, "factory"):
default = None
default_factory = f.default.factory
else:
default = f.default
default_factory = None
field = cls(
name=f.name,
annotation=f.type,
default=default or missing,
default_factory=default_factory or missing,
metadata=f.metadata,
)
fields.append(field)
return fields


def fields(cls: type):
class_type = ClassTypes.from_cls(cls)
if class_type == ClassTypes.dataclass:
return Field.from_dataclass(cls)
if class_type is None:
raise ValueError(
f"'{cls.__qualname__}' is not a currently supported kind of class. "
"Must be one of: dataclass, pydantic, or attrs class."
)

return class_type.value.collect(cls)


if class_type == ClassTypes.pydantic_v1:
return Field.from_pydantic_v1(cls)
class ClassTypes(Enum):
attrs = AttrsField
dataclass = DataclassField
pydantic_v1 = PydanticV1Field
pydantic_v2 = PydanticV2Field
pydantic_v2_dataclass = PydanticV2DataclassField
msgspec = MsgspecField

if class_type == ClassTypes.pydantic_v2:
return Field.from_pydantic_v2(cls)
@classmethod
def from_cls(cls, obj: type) -> ClassTypes | None:
if hasattr(obj, "__pydantic_fields__"):
return cls.pydantic_v2_dataclass

if class_type == ClassTypes.pydantic_v2_dataclass:
return Field.from_pydantic_v2_dataclass(cls)
if dataclasses.is_dataclass(obj):
return cls.dataclass

if class_type == ClassTypes.attrs:
return Field.from_attrs(cls)
if hasattr(obj, "__struct_config__"):
assert obj.__struct_config__.__class__.__module__.startswith("msgspec")
return cls.msgspec

raise NotImplementedError() # pragma: no cover
try:
import pydantic
except ImportError: # pragma: no cover
pass
else:
try:
is_base_model = isinstance(obj, type) and issubclass(
obj, pydantic.BaseModel
)
except TypeError: # pragma: no cover
is_base_model = False

if is_base_model:
if pydantic.__version__.startswith("1."):
return cls.pydantic_v1
return cls.pydantic_v2

if hasattr(obj, "__attrs_attrs__"):
return cls.attrs

return None


def extract_dataclass_metadata(field: Field) -> Arg | Subcommand | None:
Expand Down
2 changes: 1 addition & 1 deletion tests/ext/test_docutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def create_directive(*, style, cls_name="Foo", terminal_width=0):
"cappa",
[f"tests.ext.test_docutils.{cls_name}"],
{"style": style, "terminal-width": terminal_width},
[],
[], # type: ignore
0,
0,
"",
Expand Down
4 changes: 2 additions & 2 deletions tests/test_class_inspect.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import pytest
from cappa.class_inspect import ClassTypes
from cappa.class_inspect import fields


def test_invalid_class_base():
class Random:
...

with pytest.raises(ValueError) as e:
ClassTypes.from_cls(Random)
fields(Random)
assert (
"'test_invalid_class_base.<locals>.Random' is not a currently supported kind of class."
in str(e.value)
Expand Down
40 changes: 40 additions & 0 deletions tests/test_msgspec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

from typing import Optional

import cappa
import msgspec
from typing_extensions import Annotated

from tests.utils import backends, parse


class PydanticCommand(msgspec.Struct):
name: str
foo: Annotated[int, cappa.Arg(short=True)]


@backends
def test_base_model(backend):
result = parse(PydanticCommand, "meow", "-f", "4", backend=backend)
assert result == PydanticCommand(name="meow", foo=4)


class OptSub(msgspec.Struct):
name: Optional[str] = None


class OptionalSubcommand(msgspec.Struct):
sub: cappa.Subcommands[Optional[OptSub]] = None


@backends
def test_optional_subcommand(backend):
result = parse(OptionalSubcommand, backend=backend)
assert result == OptionalSubcommand(sub=None)

result = parse(OptionalSubcommand, "opt-sub", backend=backend)
assert result == OptionalSubcommand(sub=OptSub(name=None))

result = parse(OptionalSubcommand, "opt-sub", "foo", backend=backend)
assert result == OptionalSubcommand(sub=OptSub(name="foo"))

0 comments on commit 98a933d

Please sign in to comment.