diff --git a/dcargs/_arguments.py b/dcargs/_arguments.py index 4f434acd..5f2e1059 100644 --- a/dcargs/_arguments.py +++ b/dcargs/_arguments.py @@ -18,7 +18,7 @@ class ArgumentDefinition: name: str field: dataclasses.Field parent_class: Type - type: Optional[Type[Any]] + type: Optional[Union[Type, TypeVar]] # Fields that will be handled by argument transformations required: Optional[bool] = None @@ -64,15 +64,16 @@ def make_from_field( role: _construction.FieldRole = _construction.FieldRole.VANILLA_FIELD def _handle_generics(arg: ArgumentDefinition) -> _ArgumentTransformOutput: - return ( - dataclasses.replace( - arg, - type=type_from_typevar[arg.type] # type:ignore - if arg.type in type_from_typevar - else arg.type, - ), - None, - ) + if isinstance(arg.type, TypeVar): + assert arg.type in type_from_typevar, "TypeVar not bounded" + return ( + dataclasses.replace( + arg, type=type_from_typevar[arg.type] # type:ignore + ), + None, + ) + else: + return arg, None while True: for transform in [_handle_generics] + _argument_transforms: # type: ignore diff --git a/dcargs/_docstrings.py b/dcargs/_docstrings.py index 0d05fb8f..b23000a7 100644 --- a/dcargs/_docstrings.py +++ b/dcargs/_docstrings.py @@ -47,8 +47,15 @@ def get_field_docstring(cls: Type, field_name: str) -> Optional[str]: if isinstance(cls, _GenericAlias): cls = cls.__origin__ + assert dataclasses.is_dataclass(cls) if cls not in _cached_tokenization: - _cached_tokenization[cls] = _Tokenization.make(cls) + try: + _cached_tokenization[cls] = _Tokenization.make(cls) + except OSError as e: + # Dynamic dataclasses + assert "could not find class definition" in e.args[0] + return None + tokens = _cached_tokenization[cls].tokens tokens_from_line = _cached_tokenization[cls].tokens_from_line @@ -87,7 +94,6 @@ def get_field_docstring(cls: Type, field_name: str) -> Optional[str]: return comment[1:].strip() # Check for comment on the line before the field - # TODO: this may result in unintentional helptext? comment_index = field_index comments: List[str] = [] while True: diff --git a/dcargs/_parsers.py b/dcargs/_parsers.py index adb1bc7d..316b167c 100644 --- a/dcargs/_parsers.py +++ b/dcargs/_parsers.py @@ -23,20 +23,16 @@ class ParserDefinition: def apply(self, parser: argparse.ArgumentParser) -> None: """Create defined arguments and subparsers.""" - groups: Dict[str, Union[argparse.ArgumentParser, argparse._ArgumentGroup]] = { - "optional arguments": parser, - } + # Put required group at start of group list + required_group = parser.add_argument_group("required arguments") + parser._action_groups = parser._action_groups[::-1] # Add each argument for arg in self.args: if arg.required: - group = "required arguments" + arg.add_argument(required_group) else: - group = "optional arguments" - - if group not in groups: - groups[group] = parser.add_argument_group(group) - arg.add_argument(groups[group]) + arg.add_argument(parser) # Add subparsers if self.subparsers is not None: diff --git a/tests/test_dcargs.py b/tests/test_dcargs.py index 2919c193..e4e41f0b 100644 --- a/tests/test_dcargs.py +++ b/tests/test_dcargs.py @@ -1,8 +1,6 @@ import dataclasses import enum -import io import pathlib -from contextlib import redirect_stdout from typing import ClassVar, List, Optional, Sequence, Tuple, Union import pytest @@ -385,24 +383,3 @@ class OptionalSubparser: dcargs.parse(OptionalSubparser, args=["--x", "1", "B", "--z", "3"]) with pytest.raises(SystemExit): dcargs.parse(OptionalSubparser, args=["--x", "1", "C", "--y", "3"]) - - -def test_helptext(): - @dataclasses.dataclass - class Helptext: - x: int # Documentation 1 - - # Documentation 2 - y: int - - z: int = 3 - """Documentation 3""" - - f = io.StringIO() - with pytest.raises(SystemExit): - with redirect_stdout(f): - dcargs.parse(Helptext, args=["--help"]) - helptext = f.getvalue() - assert "--x INT Documentation 1\n" in helptext - assert "--y INT Documentation 2\n" in helptext - assert "--z INT Documentation 3 (default: 3)\n" in helptext diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py new file mode 100644 index 00000000..a5a1f6cd --- /dev/null +++ b/tests/test_docstrings.py @@ -0,0 +1,57 @@ +import contextlib +import dataclasses +import io + +import pytest + +import dcargs + + +def test_helptext(): + @dataclasses.dataclass + class Helptext: + x: int # Documentation 1 + + # Documentation 2 + y: int + + z: int = 3 + """Documentation 3""" + + f = io.StringIO() + with pytest.raises(SystemExit): + with contextlib.redirect_stdout(f): + dcargs.parse(Helptext, args=["--help"]) + helptext = f.getvalue() + assert "--x INT Documentation 1\n" in helptext + assert "--y INT Documentation 2\n" in helptext + assert "--z INT Documentation 3 (default: 3)\n" in helptext + + +def test_multiline_helptext(): + @dataclasses.dataclass + class HelptextMultiline: + x: int # Documentation 1 + + # Documentation 2 + # Next line of documentation 2 + y: int + + z: int = 3 + """Documentation 3 + Next line of documentation 3""" + + f = io.StringIO() + with pytest.raises(SystemExit): + with contextlib.redirect_stdout(f): + dcargs.parse(HelptextMultiline, args=["--help"]) + helptext = f.getvalue() + assert " --x INT Documentation 1\n" in helptext + assert ( + " --y INT Documentation 2\n Next line of documentation 2\n" + in helptext + ) + assert ( + " --z INT Documentation 3\n Next line of documentation 3 (default: 3)\n" + in helptext + ) diff --git a/tests/test_dynamic_dataclasses.py b/tests/test_dynamic_dataclasses.py new file mode 100644 index 00000000..639f5b23 --- /dev/null +++ b/tests/test_dynamic_dataclasses.py @@ -0,0 +1,14 @@ +from dataclasses import field, make_dataclass + +import pytest + +import dcargs + + +def test_dynamic(): + B = make_dataclass("B", [("c", int, field())]) + A = make_dataclass("A", [("b", B, field())]) + + with pytest.raises(SystemExit): + dcargs.parse(A, args=[]) + assert dcargs.parse(A, args=["--b.c", "5"]) == A(b=B(c=5))