Skip to content

Commit

Permalink
Support booleans in fixed-length tuples
Browse files Browse the repository at this point in the history
  • Loading branch information
brentyi committed Jan 14, 2022
1 parent e2dd680 commit 191d57b
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 112 deletions.
44 changes: 41 additions & 3 deletions dcargs/_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@

from . import _construction, _docstrings, _strings

T = TypeVar("T")


def _instance_from_string(typ: Type[T], arg: str) -> T:
"""Given a type and and a string from the command-line, reconstruct an object. Not
intended to deal with generic types or containers; these are handled in the
"argument transformations" below.
This is intended to replace all calls to `type(string)`, which can cause unexpected
behavior. As an example, note that the following argparse code will always print
`True`, because `bool("True") == bool("False") == bool("0") == True`.
```
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--flag", type=bool)
print(parser.parse_args().flag)
```
"""
if typ is bool:
return _strings.bool_from_string(arg) # type: ignore
else:
return typ(arg) # type: ignore


@dataclasses.dataclass(frozen=True)
class ArgumentDefinition:
Expand Down Expand Up @@ -38,6 +63,16 @@ def add_argument(
name = "--" + kwargs.pop("name").replace("_", "-")
kwargs.pop("field")
kwargs.pop("parent_class")

# Wrap the raw type with handling for special types. (currently only booleans)
# This feels a like a bit of band-aid; in the future, we may want to always set
# the argparse type to str and fold this logic into a more general version of
# what we currently call the "field role" (callables used for reconstructing
# lists, tuples, sets, etc).
if "type" in kwargs:
raw_type = kwargs["type"]
kwargs["type"] = lambda arg: _instance_from_string(raw_type, arg)

parser.add_argument(name, **kwargs)

def prefix(self, prefix: str) -> "ArgumentDefinition":
Expand Down Expand Up @@ -214,7 +249,6 @@ def _bool_flags(arg: ArgumentDefinition) -> _ArgumentTransformOutput:
return (
dataclasses.replace(
arg,
type=_strings.bool_from_string, # type: ignore
metavar="{True,False}",
),
None,
Expand Down Expand Up @@ -301,17 +335,21 @@ def _nargs_from_tuples(arg: ArgumentDefinition) -> _ArgumentTransformOutput:
else:
# Tuples with more than one type
assert arg.metavar is None

return (
dataclasses.replace(
arg,
nargs=len(types),
type=str, # Types will be converted in the dataclass reconstruction step
type=str, # Types will be converted in the dataclass reconstruction step.
metavar=tuple(
t.__name__.upper() if hasattr(t, "__name__") else "X"
for t in types
),
),
lambda str_list: tuple(typ(x) for typ, x in zip(types, str_list)),
# Field role: convert lists of strings to tuples of the correct types.
lambda str_list: tuple(
_instance_from_string(typ, x) for typ, x in zip(types, str_list)
),
)

else:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

setup(
name="dcargs",
version="0.0.11",
version="0.0.12",
description="Portable, reusable, strongly typed CLIs from dataclass definitions",
long_description=long_description,
long_description_content_type="text/markdown",
Expand Down
153 changes: 153 additions & 0 deletions tests/test_collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import dataclasses
from typing import List, Optional, Sequence, Set, Tuple

import pytest

import dcargs


def test_tuples_fixed():
@dataclasses.dataclass
class A:
x: Tuple[int, int, int]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=(1, 2, 3))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_fixed_multitype():
@dataclasses.dataclass
class A:
x: Tuple[int, str, float]

assert dcargs.parse(A, args=["--x", "1", "2", "3.5"]) == A(x=(1, "2", 3.5))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_fixed_bool():
@dataclasses.dataclass
class A:
x: Tuple[bool, bool, bool]

assert dcargs.parse(A, args=["--x", "True", "True", "False"]) == A(
x=(True, True, False)
)
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_variable():
@dataclasses.dataclass
class A:
x: Tuple[int, ...]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=(1, 2, 3))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_variable_bool():
@dataclasses.dataclass
class A:
x: Tuple[bool, ...]

assert dcargs.parse(A, args=["--x", "True", "True", "False"]) == A(
x=(True, True, False)
)
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_variable_optional():
@dataclasses.dataclass
class A:
x: Optional[Tuple[int, ...]]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=(1, 2, 3))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
assert dcargs.parse(A, args=[]) == A(x=None)


def test_sequences():
@dataclasses.dataclass
class A:
x: Sequence[int]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_lists():
@dataclasses.dataclass
class A:
x: List[int]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_lists_bool():
@dataclasses.dataclass
class A:
x: List[bool]

assert dcargs.parse(A, args=["--x", "True", "False", "True"]) == A(
x=[True, False, True]
)
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_sets():
@dataclasses.dataclass
class A:
x: Set[int]

assert dcargs.parse(A, args=["--x", "1", "2", "3", "3"]) == A(x={1, 2, 3})
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_optional_sequences():
@dataclasses.dataclass
class A:
x: Optional[Sequence[int]]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
assert dcargs.parse(A, args=[]) == A(x=None)


def test_optional_lists():
@dataclasses.dataclass
class A:
x: Optional[List[int]]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
assert dcargs.parse(A, args=[]) == A(x=None)
107 changes: 1 addition & 106 deletions tests/test_dcargs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import dataclasses
import enum
import pathlib
from typing import ClassVar, List, Optional, Sequence, Set, Tuple, Union
from typing import ClassVar, Optional, Union

import pytest
from typing_extensions import Annotated, Final, Literal # Backward compatibility.
Expand Down Expand Up @@ -116,111 +116,6 @@ class A:
assert dcargs.parse(A, args=[]) == A(x=None)


def test_sequences():
@dataclasses.dataclass
class A:
x: Sequence[int]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_lists():
@dataclasses.dataclass
class A:
x: List[int]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_sets():
@dataclasses.dataclass
class A:
x: Set[int]

assert dcargs.parse(A, args=["--x", "1", "2", "3", "3"]) == A(x={1, 2, 3})
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_optional_sequences():
@dataclasses.dataclass
class A:
x: Optional[Sequence[int]]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
assert dcargs.parse(A, args=[]) == A(x=None)


def test_optional_lists():
@dataclasses.dataclass
class A:
x: Optional[List[int]]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=[1, 2, 3])
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
assert dcargs.parse(A, args=[]) == A(x=None)


def test_tuples_fixed():
@dataclasses.dataclass
class A:
x: Tuple[int, int, int]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=(1, 2, 3))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_fixed_multitype():
@dataclasses.dataclass
class A:
x: Tuple[int, str, float]

assert dcargs.parse(A, args=["--x", "1", "2", "3.5"]) == A(x=(1, "2", 3.5))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_variable():
@dataclasses.dataclass
class A:
x: Tuple[int, ...]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=(1, 2, 3))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
with pytest.raises(SystemExit):
dcargs.parse(A, args=[])


def test_tuples_variable_optional():
@dataclasses.dataclass
class A:
x: Optional[Tuple[int, ...]]

assert dcargs.parse(A, args=["--x", "1", "2", "3"]) == A(x=(1, 2, 3))
with pytest.raises(SystemExit):
dcargs.parse(A, args=["--x"])
assert dcargs.parse(A, args=[]) == A(x=None)


def test_enum():
class Color(enum.Enum):
RED = enum.auto()
Expand Down
4 changes: 2 additions & 2 deletions tests/test_docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class Child2(Parent2):
x: str = (
"This docstring may be tougher to parse?"
)
"""Helptext."""
"""Helptext!"""
# fmt: on

f = io.StringIO()
Expand All @@ -144,7 +144,7 @@ class Child2(Parent2):
dcargs.parse(Child2, args=["--help"])
helptext = f.getvalue()
assert (
"--x STR Helptext. (default: This docstring may be tougher to parse?)\n"
"--x STR Helptext! (default: This docstring may be tougher to parse?)\n"
in helptext
)

Expand Down

0 comments on commit 191d57b

Please sign in to comment.