From 341762f677715bb27d0da7c7c2ed4ca821a4abd3 Mon Sep 17 00:00:00 2001 From: oir Date: Sat, 21 Feb 2026 22:17:46 -0500 Subject: [PATCH 1/3] add `recurse` and `naming` parameters to `parse()` --- startle/_inspect/make_args.py | 26 +- startle/_inspect/tree.py | 4 + startle/_parse.py | 8 +- tests/test_recursive_parse.py | 351 ++++++++++++++++++ ...t_recursive.py => test_recursive_start.py} | 0 5 files changed, 374 insertions(+), 15 deletions(-) create mode 100644 tests/test_recursive_parse.py rename tests/{test_recursive.py => test_recursive_start.py} (100%) diff --git a/startle/_inspect/make_args.py b/startle/_inspect/make_args.py index 2c35e9a..951ec6e 100644 --- a/startle/_inspect/make_args.py +++ b/startle/_inspect/make_args.py @@ -80,7 +80,7 @@ def _reserve_short_names(params: Sequence[Param]): return used_short_names, short_name_assignments -def make_arg_from_param(param: Param, name: Name, kw_only: bool = False) -> Arg: +def _make_arg_from_param(param: Param, name: Name, kw_only: bool = False) -> Arg: return Arg( name=name, type_=param.normalized_hint, # type: ignore @@ -95,7 +95,7 @@ def make_arg_from_param(param: Param, name: Name, kw_only: bool = False) -> Arg: ) -def make_args_from_params_flat( +def _make_args_from_params_flat( params: Sequence[Param], brief: str = "", program_name: str = "" ) -> Args: args = Args(brief=brief, program_name=program_name) @@ -114,7 +114,7 @@ def make_args_from_params_flat( used_short_names.add(first_char) short_name_assignments[param.name] = first_char short = first_char - arg = make_arg_from_param( + arg = _make_arg_from_param( param=param, name=Name(long=param.name.replace("_", "-"), short=short), ) @@ -130,7 +130,7 @@ def make_args_from_params_flat( return args -def make_args_from_params_recursive( +def _make_args_from_params_recursive( params: Sequence[Param], brief: str = "", program_name: str = "", @@ -180,7 +180,7 @@ def traverse(node: TreeNode[Param], args: Args, parent_name: str = ""): short = first_char name = Name(long=param.name.replace("_", "-"), short=short) - arg = make_arg_from_param( + arg = _make_arg_from_param( param=param, name=name, kw_only=kw_only, @@ -279,13 +279,13 @@ def make_args_from_func( ] if not recurse: - return make_args_from_params_flat( + return _make_args_from_params_flat( params=params, brief=brief, program_name=program_name, ) else: - return make_args_from_params_recursive( + return _make_args_from_params_recursive( params=params, brief=brief, program_name=program_name, @@ -293,7 +293,7 @@ def make_args_from_func( ) -def make_params_from_class(cls: type) -> list[Param]: +def _make_params_from_class(cls: type) -> list[Param]: params = get_initializer_parameters(cls) hints = get_type_hints(cls.__init__, include_extras=True) _, arg_helps = parse_docstring(cls) @@ -311,7 +311,7 @@ def make_params_from_class(cls: type) -> list[Param]: ] -def make_params_from_td(cls: type) -> list[Param]: +def _make_params_from_td(cls: type) -> list[Param]: params = get_type_hints(cls, include_extras=True).items() optional_keys = cast(frozenset[str], cls.__optional_keys__) # type: ignore required_keys = cast(frozenset[str], cls.__required_keys__) # type: ignore @@ -351,18 +351,18 @@ def make_args_from_class( # TODO: check if cls is a class? if is_typeddict(cls): - params = make_params_from_td(cls) + params = _make_params_from_td(cls) else: - params = make_params_from_class(cls) + params = _make_params_from_class(cls) if not recurse: - return make_args_from_params_flat( + return _make_args_from_params_flat( params=params, brief=brief, program_name=program_name, ) else: - return make_args_from_params_recursive( + return _make_args_from_params_recursive( params=params, brief=brief, program_name=program_name, diff --git a/startle/_inspect/tree.py b/startle/_inspect/tree.py index f7807e8..900a981 100644 --- a/startle/_inspect/tree.py +++ b/startle/_inspect/tree.py @@ -1,3 +1,7 @@ +""" +Utilities for recursive inspection when parsing nested structures. +""" + from collections.abc import Iterable from dataclasses import dataclass, is_dataclass from typing import Generic, TypeVar, cast, get_type_hints diff --git a/startle/_parse.py b/startle/_parse.py index 79519ef..9bee24b 100644 --- a/startle/_parse.py +++ b/startle/_parse.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import Literal, TypeVar from rich.console import Console from rich.text import Text @@ -16,6 +16,8 @@ def parse( args: list[str] | None = None, brief: str = "", catch: bool = True, + recurse: bool = False, + naming: Literal["flat", "nested"] = "flat", ) -> T: """ Given a class `cls`, parse arguments from the command-line according to the @@ -36,7 +38,9 @@ class definition and construct an instance. An instance of the class `cls`. """ # first, make Args object from the class - args_ = make_args_from_class(cls, brief=brief, program_name=name or "") + args_ = make_args_from_class( + cls, brief=brief, program_name=name or "", recurse=recurse, naming=naming + ) try: # then, parse the arguments from the CLI diff --git a/tests/test_recursive_parse.py b/tests/test_recursive_parse.py new file mode 100644 index 0000000..c25981b --- /dev/null +++ b/tests/test_recursive_parse.py @@ -0,0 +1,351 @@ +from dataclasses import dataclass, field +from typing import Any, Literal, TypedDict + +from pytest import mark, raises +from startle import parse +from startle.error import ParserConfigError, ParserOptionError + + +@dataclass +class DieConfig: + """ + Configuration for the dice program. + + Attributes: + sides: The number of sides on the dice. + kind: Whether to throw a single die or a pair of dice. + """ + + sides: int = 6 + kind: Literal["single", "pair"] = "single" + + +@dataclass +class MainConfig: + """ + Main configuration for the dice program. + + Attributes: + cfg: The configuration for the dice. + count: The number of dice to throw. + """ + + cfg: DieConfig + count: int = 1 + + +@dataclass +class MainConfigUnion: + """ + Main configuration for the dice program. + + Attributes: + cfg: The configuration for the dice or a string. + count: The number of dice to throw. + """ + + cfg: DieConfig | str + count: int = 1 + + +@mark.parametrize("sides_opt", ["--sides", "-s"]) +@mark.parametrize("kind_opt", ["--kind", "-k"]) +@mark.parametrize("count_opt", ["--count", "-c"]) +@mark.parametrize("sides", [4, 6, None]) +@mark.parametrize("kind", ["single", "pair", None]) +@mark.parametrize("count", [1, 2, None]) +def test_recursive_w_defaults( + sides_opt: str, + kind_opt: str, + count_opt: str, + sides: int | None, + kind: Literal["single", "pair"] | None, + count: int | None, +) -> None: + cli_args: list[str] = [] + config_kwargs = {} + if sides is not None: + cli_args += [sides_opt, str(sides)] + config_kwargs["sides"] = sides + if kind is not None: + cli_args += [kind_opt, kind] + config_kwargs["kind"] = kind + if count is not None: + cli_args += [count_opt, str(count)] + + # expected_cfg = DieConfig(**config_kwargs) # type: ignore[arg-type] + # expected_count = count if count is not None else 1 + cfg = parse(MainConfig, args=cli_args, recurse=True) + + assert cfg == MainConfig( + cfg=DieConfig(**config_kwargs), # type: ignore[arg-type] + count=count if count is not None else 1, + ) + + with raises( + ParserConfigError, + match="Cannot recurse into parameter `cfg` of non-class type `DieConfig | str` in `MainConfigUnion`!", + ): + parse(MainConfigUnion, args=cli_args, recurse=True) + + +@mark.parametrize("count_opt", ["--count", "-c"]) +@mark.parametrize("sides", [4, 6, None]) +@mark.parametrize("kind", ["single", "pair", None]) +@mark.parametrize("count", [1, 2, None]) +def test_recursive_w_defaults_nested( + count_opt: str, + sides: int | None, + kind: Literal["single", "pair"] | None, + count: int | None, +) -> None: + cli_args: list[str] = [] + config_kwargs = {} + if sides is not None: + cli_args += ["--cfg.sides", str(sides)] + config_kwargs["sides"] = sides + if kind is not None: + cli_args += ["--cfg.kind", kind] + config_kwargs["kind"] = kind + if count is not None: + cli_args += [count_opt, str(count)] + + cfg = parse(MainConfig, args=cli_args, recurse=True, naming="nested") + assert cfg == MainConfig( + cfg=DieConfig(**config_kwargs), # type: ignore[arg-type] + count=count if count is not None else 1, + ) + + with raises( + ParserConfigError, + match="Cannot recurse into parameter `cfg` of non-class type `DieConfig | str` in `MainConfigUnion`!", + ): + parse(MainConfigUnion, args=cli_args, recurse=True, naming="nested") + + +@dataclass +class DieConfig2: + """ + Configuration for the dice program. + + Attributes: + sides: The number of sides on the dice. + kind: Whether to throw a single die or a pair of dice. + """ + + sides: int + kind: Literal["single", "pair"] + + +@dataclass +class MainConfig2: + """ + Main configuration for the dice program. + + Attributes: + cfg: The configuration for the dice. + count: The number of dice to throw. + """ + + cfg: DieConfig2 + count: int + + +class DieConfig2TD(TypedDict): + """ + Configuration for the dice program. + + Attributes: + sides: The number of sides on the dice. + kind: Whether to throw a single die or a pair of dice. + """ + + sides: int + kind: Literal["single", "pair"] + + +@dataclass +class MainConfig2TD: + """ + Main configuration for the dice program. + + Attributes: + cfg: The configuration for the dice. + count: The number of dice to throw. + """ + + cfg: DieConfig2TD + count: int + + +@mark.parametrize("sides_opt", ["--sides", "-s"]) +@mark.parametrize("kind_opt", ["--kind", "-k"]) +@mark.parametrize("count_opt", ["--count", "-c"]) +@mark.parametrize("sides", [4, 6, None]) +@mark.parametrize("kind", ["single", "pair", None]) +@mark.parametrize("count", [1, 2, None]) +@mark.parametrize("cls", [DieConfig2, DieConfig2TD]) +def test_recursive_w_required( + sides_opt: str, + kind_opt: str, + count_opt: str, + sides: int | None, + kind: Literal["single", "pair"] | None, + count: int | None, + cls: type, +) -> None: + if cls is DieConfig2: + outer = MainConfig2 + else: + outer = MainConfig2TD + cli_args: list[str] = [] + config_kwargs = {} + if sides is not None: + cli_args += [sides_opt, str(sides)] + config_kwargs["sides"] = sides + if kind is not None: + cli_args += [kind_opt, kind] + config_kwargs["kind"] = kind + if count is not None: + cli_args += [count_opt, str(count)] + + if sides is None: + with raises( + ParserOptionError, match="Required option `sides` is not provided!" + ): + parse(outer, args=cli_args, recurse=True, catch=False) + elif kind is None: + with raises(ParserOptionError, match="Required option `kind` is not provided!"): + parse(outer, args=cli_args, recurse=True, catch=False) + elif count is None: + with raises( + ParserOptionError, match="Required option `count` is not provided!" + ): + parse(outer, args=cli_args, recurse=True, catch=False) + else: + cfg = parse(outer, args=cli_args, recurse=True) + expected_inner_cfg: DieConfig2 | dict[str, Any] = ( + DieConfig2(**config_kwargs) if cls is DieConfig2 else {**config_kwargs} # type: ignore[arg-type] + ) + expected_count = count + assert cfg == outer( + cfg=expected_inner_cfg, # type: ignore[arg-type] + count=expected_count, + ) + + +@mark.parametrize("count_opt", ["--count", "-c"]) +@mark.parametrize("sides", [4, 6, None]) +@mark.parametrize("kind", ["single", "pair", None]) +@mark.parametrize("count", [1, 2, None]) +@mark.parametrize("cls", [DieConfig2, DieConfig2TD]) +def test_recursive_w_required_nested( + count_opt: str, + sides: int | None, + kind: Literal["single", "pair"] | None, + count: int | None, + cls: type, +) -> None: + if cls is DieConfig2: + outer = MainConfig2 + else: + outer = MainConfig2TD + cli_args: list[str] = [] + config_kwargs = {} + if sides is not None: + cli_args += ["--cfg.sides", str(sides)] + config_kwargs["sides"] = sides + if kind is not None: + cli_args += ["--cfg.kind", kind] + config_kwargs["kind"] = kind + if count is not None: + cli_args += [count_opt, str(count)] + + if sides is None: + with raises( + ParserOptionError, match="Required option `cfg.sides` is not provided!" + ): + parse(outer, args=cli_args, recurse=True, naming="nested", catch=False) + elif kind is None: + with raises( + ParserOptionError, match="Required option `cfg.kind` is not provided!" + ): + parse(outer, args=cli_args, recurse=True, naming="nested", catch=False) + elif count is None: + with raises( + ParserOptionError, match="Required option `count` is not provided!" + ): + parse(outer, args=cli_args, recurse=True, naming="nested", catch=False) + else: + expected_cfg: DieConfig2 | dict[str, Any] = ( + DieConfig2(**config_kwargs) if cls is DieConfig2 else {**config_kwargs} # type: ignore[arg-type] + ) + expected_count = count + cfg = parse(outer, args=cli_args, recurse=True, naming="nested") + assert cfg == outer( + cfg=expected_cfg, # type: ignore[arg-type] + count=expected_count, + ) + + +@dataclass +class MainConfig3: + """ + Main configuration for the dice program. + + Attributes: + count: The number of dice to throw. + cfg: The configuration for the dice. + """ + + count: int + cfg: DieConfig2 = field(default_factory=lambda: DieConfig2(sides=6, kind="single")) + + +@dataclass +class MainConfig4: + """ + Main configuration for the dice program. + + Attributes: + count: The number of dice to throw. + cfg: The configuration for the dice. + """ + + count: int + cfg: DieConfig2 | None = None + + +def test_recursive_w_inner_required() -> None: + cfg = parse( + MainConfig3, + args=["--sides", "4", "--kind", "pair", "--count", "2"], + recurse=True, + catch=False, + ) + assert cfg == MainConfig3(count=2, cfg=DieConfig2(sides=4, kind="pair")) + + cfg = parse( + MainConfig4, + args=["--sides", "4", "--kind", "pair", "--count", "2"], + recurse=True, + catch=False, + ) + assert cfg == MainConfig4(count=2, cfg=DieConfig2(sides=4, kind="pair")) + + # DieConfig2 requires sides and kind, however cfg has a default. + cfg = parse( + MainConfig3, + args=["--count", "2"], + recurse=True, + catch=False, + ) + assert cfg == MainConfig3(count=2, cfg=DieConfig2(sides=6, kind="single")) + + cfg = parse( + MainConfig4, + args=["--count", "2"], + recurse=True, + catch=False, + ) + assert cfg == MainConfig4(count=2, cfg=None) diff --git a/tests/test_recursive.py b/tests/test_recursive_start.py similarity index 100% rename from tests/test_recursive.py rename to tests/test_recursive_start.py From 7ccf1f4a28015df2e86bfaaae9b41a8d37924aed Mon Sep 17 00:00:00 2001 From: oir Date: Sun, 22 Feb 2026 13:21:49 -0500 Subject: [PATCH 2/3] more testing --- tests/test_help/_utils.py | 10 +- tests/test_recursive_parse.py | 481 ++++++++++++++++++++++++++++++++++ tests/test_recursive_start.py | 26 +- 3 files changed, 500 insertions(+), 17 deletions(-) diff --git a/tests/test_help/_utils.py b/tests/test_help/_utils.py index 55df3d0..30b63c4 100644 --- a/tests/test_help/_utils.py +++ b/tests/test_help/_utils.py @@ -35,12 +35,14 @@ def check_help_from_func( assert remove_trailing_spaces(result) == remove_trailing_spaces(expected) -def check_help_from_class(cls: type, brief: str, program_name: str, expected: str): +def check_help_from_class( + cls: type, brief: str, program_name: str, expected: str, recurse: bool = False +): console = Console(width=120, highlight=False, force_terminal=True) with console.capture() as capture: - make_args_from_class(cls, program_name=program_name, brief=brief).print_help( - console - ) + make_args_from_class( + cls, program_name=program_name, brief=brief, recurse=recurse + ).print_help(console) result = capture.get() console = Console(width=120, highlight=False, force_terminal=True) diff --git a/tests/test_recursive_parse.py b/tests/test_recursive_parse.py index c25981b..4d575a3 100644 --- a/tests/test_recursive_parse.py +++ b/tests/test_recursive_parse.py @@ -1,3 +1,4 @@ +import re from dataclasses import dataclass, field from typing import Any, Literal, TypedDict @@ -5,6 +6,14 @@ from startle import parse from startle.error import ParserConfigError, ParserOptionError +from .test_help._utils import ( + NS, + OS, + TS, + VS, + check_help_from_class, +) + @dataclass class DieConfig: @@ -349,3 +358,475 @@ def test_recursive_w_inner_required() -> None: catch=False, ) assert cfg == MainConfig4(count=2, cfg=None) + + +def test_recursive_w_inner_required_nested() -> None: + cfg = parse( + MainConfig3, + args=["--cfg.sides", "4", "--cfg.kind", "pair", "--count", "2"], + recurse=True, + naming="nested", + catch=False, + ) + assert cfg == MainConfig3(count=2, cfg=DieConfig2(sides=4, kind="pair")) + + cfg = parse( + MainConfig4, + args=["--cfg.sides", "4", "--cfg.kind", "pair", "--count", "2"], + recurse=True, + naming="nested", + catch=False, + ) + assert cfg == MainConfig4(count=2, cfg=DieConfig2(sides=4, kind="pair")) + + # DieConfig2 requires sides and kind, however cfg has a default. + cfg = parse( + MainConfig3, + args=["--count", "2"], + recurse=True, + naming="nested", + catch=False, + ) + assert cfg == MainConfig3(count=2, cfg=DieConfig2(sides=6, kind="single")) + + cfg = parse( + MainConfig4, + args=["--count", "2"], + recurse=True, + naming="nested", + catch=False, + ) + assert cfg == MainConfig4(count=2, cfg=None) + + +class ConfigWithVarArgs: + def __init__(self, *values: int) -> None: + self.values = list(values) + + +class ConfigWithVarKwargs: + def __init__(self, **settings: int) -> None: + self.settings = settings + + +@dataclass +class NestedConfigWithVarArgs: + config: ConfigWithVarArgs + + +class NestedTypedDictWithVarArgs(TypedDict): + config: ConfigWithVarArgs + + +@mark.parametrize("naming", ["flat", "nested"]) +def test_recursive_unsupported(naming: Literal["flat", "nested"]) -> None: + @dataclass + class C1: + cfg: ConfigWithVarArgs + + @dataclass + class C2: + cfg: ConfigWithVarKwargs + + @dataclass + class C3: + cfg: NestedConfigWithVarArgs + + @dataclass + class C4: + cfg: NestedTypedDictWithVarArgs + + with raises( + ParserConfigError, + match="Cannot have variadic parameter `values` in child Args of `ConfigWithVarArgs`!", + ): + parse(C1, args=[], recurse=True, naming=naming) + with raises( + ParserConfigError, + match="Cannot have variadic parameter `settings` in child Args of `ConfigWithVarKwargs`!", + ): + parse(C2, args=[], recurse=True, naming=naming) + with raises( + ParserConfigError, + match="Cannot have variadic parameter `values` in child Args of `ConfigWithVarArgs`!", + ): + parse(C3, args=[], recurse=True, naming=naming) + with raises( + ParserConfigError, + match="Cannot have variadic parameter `values` in child Args of `ConfigWithVarArgs`!", + ): + parse(C4, args=[], recurse=True, naming=naming) + + @dataclass + class C4a: + cfgs: list[DieConfig] + + @dataclass + class C4b: + cfgs: list[DieConfig2TD] + + for cls in [C4a, C4b]: + with raises( + ParserConfigError, + match=f"Cannot recurse into n-ary parameter `cfgs` in `{cls.__name__}`!", + ): + parse(cls, args=[], recurse=True, naming=naming) + + @dataclass + class C5a: + cfg: DieConfig + sides: int + + @dataclass + class C5b: + cfg: DieConfig2TD + sides: int + + if naming == "flat": + for cls in [C5a, C5b]: + with raises( + ParserConfigError, + match=re.escape( + f"Option name `sides` is used multiple times in `{cls.__name__}`!" + " Recursive parsing with `flat` naming requires unique option names among all levels." + ), + ): + parse(cls, args=[], recurse=True, naming=naming) + else: + cfg = parse( + C5a, + args=["--cfg.sides", "4", "--cfg.kind", "single", "--sides", "8"], + recurse=True, + naming=naming, + ) + assert cfg == C5a(cfg=DieConfig(sides=4, kind="single"), sides=8) + + cfg = parse( + C5b, + args=["--cfg.sides", "4", "--cfg.kind", "single", "--sides", "8"], + recurse=True, + naming=naming, + ) + assert cfg == C5b(cfg={"sides": 4, "kind": "single"}, sides=8) + + @dataclass + class C6a: + cfg: DieConfig + cfg2: DieConfig + + @dataclass + class C6b: + cfg: DieConfig + cfg2: DieConfig2TD + + @dataclass + class C6c: + cfg: DieConfig2TD + cfg2: DieConfig + + @dataclass + class C6d: + cfg: DieConfig2TD + cfg2: DieConfig2TD + + if naming == "flat": + for cls in [C6a, C6b, C6c, C6d]: + with raises( + ParserConfigError, + match=re.escape( + f"Option name `sides` is used multiple times in `{cls.__name__}`!" + " Recursive parsing with `flat` naming requires unique option names among all levels." + ), + ): + parse(cls, args=[], recurse=True, naming=naming) + else: + cfg = parse( + C6a, + args=[ + "--cfg.sides", + "4", + "--cfg.kind", + "pair", + "--cfg2.sides", + "8", + "--cfg2.kind", + "single", + ], + recurse=True, + naming="nested", + ) + assert cfg == C6a( + cfg=DieConfig(sides=4, kind="pair"), + cfg2=DieConfig(sides=8, kind="single"), + ) + + cfg = parse( + C6b, + args=[ + "--cfg.sides", + "4", + "--cfg.kind", + "pair", + "--cfg2.sides", + "8", + "--cfg2.kind", + "single", + ], + recurse=True, + naming="nested", + ) + assert cfg == C6b( + cfg=DieConfig(sides=4, kind="pair"), + cfg2={"sides": 8, "kind": "single"}, + ) + + cfg = parse( + C6c, + args=[ + "--cfg.sides", + "4", + "--cfg.kind", + "pair", + "--cfg2.sides", + "8", + "--cfg2.kind", + "single", + ], + recurse=True, + naming="nested", + ) + assert cfg == C6c( + cfg={"sides": 4, "kind": "pair"}, + cfg2=DieConfig(sides=8, kind="single"), + ) + + cfg = parse( + C6d, + args=[ + "--cfg.sides", + "4", + "--cfg.kind", + "pair", + "--cfg2.sides", + "8", + "--cfg2.kind", + "single", + ], + recurse=True, + naming="nested", + ) + assert cfg == C6d( + cfg={"sides": 4, "kind": "pair"}, + cfg2={"sides": 8, "kind": "single"}, + ) + + +@dataclass +class FusionConfig: + """ + Fusion config. + + Attributes: + left_path: Path to the first monster. + right_path: Path to the second monster. + output_path [p]: Path to store the fused monster. + components: Components to fuse. + alpha: Weighting factor for the first monster. + """ + + left_path: str + right_path: str + output_path: str + components: list[str] = field(default_factory=lambda: ["fang", "claw"]) + alpha: float = 0.5 + + +@dataclass +class InputPaths: + """ + Input paths for fusion. + + Attributes: + left_path: Path to the first monster. + right_path: Path to the second monster. + """ + + left_path: str + right_path: str + + +@dataclass +class IOPaths: + """ + Input and output paths for fusion. + + Attributes: + input_paths: Input paths for the fusion. + output_path [p]: Path to store the fused monster. + """ + + input_paths: InputPaths + output_path: str + + +@dataclass +class FusionConfig2: + """ + Fusion config with separate input and output paths. + + Attributes: + io_paths: Input and output paths for the fusion. + components: Components to fuse. + alpha: Weighting factor for the first monster. + """ + + io_paths: IOPaths + components: list[str] = field(default_factory=lambda: ["fang", "claw"]) + alpha: float = 0.5 + + +class FusionConfig2TD(TypedDict): + """ + Fusion config with separate input and output paths. + + Attributes: + io_paths: Input and output paths for the fusion. + components: Components to fuse. + alpha: Weighting factor for the first monster. + """ + + io_paths: IOPaths + components: list[str] + alpha: float + + +def fuse1(cfg: FusionConfig) -> None: + """ + Fuse two monsters with polymerization. + + Args: + cfg: The fusion configuration. + """ + pass + + +@dataclass +class Fuse1: + """ + Top level fusion config. + + Attributes: + cfg: The fusion configuration. + """ + + cfg: FusionConfig + + +@dataclass +class Fuse2: + """ + Top level fusion config. + + Attributes: + cfg: The fusion configuration. + """ + + cfg: FusionConfig2 + + +@dataclass +class Fuse2TD: + """ + Top level fusion config. + + Attributes: + cfg: The fusion configuration. + """ + + cfg: FusionConfig2TD + + +@mark.parametrize("cls", [Fuse1, Fuse2, Fuse2TD]) +def test_recursive_dataclass_help( + cls: type[Fuse1] | type[Fuse2] | type[Fuse2TD], +) -> None: + if cls is Fuse1: + expected = Fuse1( + FusionConfig( + left_path="monster1.dat", + right_path="monster2.dat", + output_path="fused_monster.dat", + components=["wing", "tail"], + alpha=0.7, + ) + ) + elif cls is Fuse2: + expected = Fuse2( + FusionConfig2( + io_paths=IOPaths( + input_paths=InputPaths( + left_path="monster1.dat", right_path="monster2.dat" + ), + output_path="fused_monster.dat", + ), + components=["wing", "tail"], + alpha=0.7, + ) + ) + else: + expected = Fuse2TD({ + "io_paths": IOPaths( + input_paths=InputPaths( + left_path="monster1.dat", right_path="monster2.dat" + ), + output_path="fused_monster.dat", + ), + "components": ["wing", "tail"], + "alpha": 0.7, + }) + + parsed = parse( + cls, + args=[ + "--left-path", + "monster1.dat", + "--right-path", + "monster2.dat", + "--output-path", + "fused_monster.dat", + "--components", + "wing", + "tail", + "--alpha", + "0.7", + ], + recurse=True, + catch=False, + ) + assert parsed == expected + + expected = f"""\ + +Fuse two monsters with polymerization. + +[{TS}]Usage:[/] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + +[{TS}]where[/] + [dim](option)[/] [{NS} {OS}]-l[/][{OS} dim]|[/][{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/] + [dim](option)[/] [{NS} {OS}]-r[/][{OS} dim]|[/][{NS} {OS}]--right-path[/] [{VS}][/] [i]Path to the second monster.[/] [yellow](required)[/] + [dim](option)[/] [{NS} {OS}]-p[/][{OS} dim]|[/][{NS} {OS}]--output-path[/] [{VS}][/] [i]Path to store the fused monster.[/] [yellow](required)[/] + [dim](option)[/] [{NS} {OS}]-c[/][{OS} dim]|[/][{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/] [i]Components to fuse.[/] [green](default: [/][green]['fang', 'claw'][/][green])[/] + [dim](option)[/] [{NS} {OS}]-a[/][{OS} dim]|[/][{NS} {OS}]--alpha[/] [{VS}][/] [i]Weighting factor for the first monster.[/] [green](default: [/][green]0.5[/][green])[/] + [dim](option)[/] [{NS} {OS} dim]-?[/][{OS} dim]|[/][{NS} {OS} dim]--help[/] [i dim]Show this help message and exit.[/] +""" + + if cls is not Fuse2TD: + # TypedDict example does not have default values, every option is required. + check_help_from_class( + cls, + brief="Fuse two monsters with polymerization.", + program_name="fuse.py", + expected=expected, + recurse=True, + ) diff --git a/tests/test_recursive_start.py b/tests/test_recursive_start.py index 2426d3c..af212e6 100644 --- a/tests/test_recursive_start.py +++ b/tests/test_recursive_start.py @@ -505,18 +505,6 @@ def f7a(cfg: DieConfig, sides: int) -> None: def f7b(cfg: DieConfig2TD, sides: int) -> None: pass - def f8a(cfg: DieConfig, cfg2: DieConfig) -> None: - pass - - def f8b(cfg: DieConfig, cfg2: DieConfig2TD) -> None: - pass - - def f8c(cfg: DieConfig2TD, cfg2: DieConfig) -> None: - pass - - def f8d(cfg: DieConfig2TD, cfg2: DieConfig2TD) -> None: - pass - if naming == "flat": for f in [f7a, f7b]: with raises( @@ -545,6 +533,18 @@ def f8d(cfg: DieConfig2TD, cfg2: DieConfig2TD) -> None: naming="nested", ) + def f8a(cfg: DieConfig, cfg2: DieConfig) -> None: + pass + + def f8b(cfg: DieConfig, cfg2: DieConfig2TD) -> None: + pass + + def f8c(cfg: DieConfig2TD, cfg2: DieConfig) -> None: + pass + + def f8d(cfg: DieConfig2TD, cfg2: DieConfig2TD) -> None: + pass + if naming == "flat": for f in [f8a, f8b, f8c, f8d]: with raises( @@ -803,7 +803,7 @@ def test_recursive_dataclass_help(fuse: Callable[..., Any]) -> None: [dim](option)[/] [{NS} {OS} dim]-?[/][{OS} dim]|[/][{NS} {OS} dim]--help[/] [i dim]Show this help message and exit.[/] """ if fuse is not fuse2td: - # TypedDict does not have default values for now, every option is required. + # TypedDict example does not have default values, every option is required. check_help_from_func(fuse, "fuse.py", expected, recurse=True) From dca3fde3d97a5d1c0e2e36755b49873e9c819be3 Mon Sep 17 00:00:00 2001 From: oir Date: Sun, 22 Feb 2026 13:43:07 -0500 Subject: [PATCH 3/3] more testing --- tests/test_recursive_parse.py | 216 ++++++++++++++++++++++++++++++++++ tests/test_recursive_start.py | 10 +- 2 files changed, 217 insertions(+), 9 deletions(-) diff --git a/tests/test_recursive_parse.py b/tests/test_recursive_parse.py index 4d575a3..d304af0 100644 --- a/tests/test_recursive_parse.py +++ b/tests/test_recursive_parse.py @@ -830,3 +830,219 @@ def test_recursive_dataclass_help( expected=expected, recurse=True, ) + + +@mark.parametrize("cls", [Fuse2, Fuse2TD]) +def test_recursive_dataclass_nested(cls: type[Fuse2] | type[Fuse2TD]) -> None: + if cls is Fuse2: + expected = Fuse2( + FusionConfig2( + io_paths=IOPaths( + input_paths=InputPaths( + left_path="monster1.dat", right_path="monster2.dat" + ), + output_path="fused_monster.dat", + ), + components=["wing", "tail"], + alpha=0.7, + ) + ) + else: + expected = Fuse2TD({ + "io_paths": IOPaths( + input_paths=InputPaths( + left_path="monster1.dat", right_path="monster2.dat" + ), + output_path="fused_monster.dat", + ), + "components": ["wing", "tail"], + "alpha": 0.7, + }) + + parsed = parse( + cls, + args=[ + "--cfg.io-paths.input-paths.left-path", + "monster1.dat", + "--cfg.io-paths.input-paths.right-path", + "monster2.dat", + "--cfg.io-paths.output-path", + "fused_monster.dat", + "--cfg.components", + "wing", + "tail", + "--cfg.alpha", + "0.7", + ], + recurse=True, + naming="nested", + catch=False, + ) + assert parsed == expected + + +@dataclass +class IOPaths2: + """ + Input and output paths for fusion. + + Attributes: + input_paths: Input paths for the fusion. + output_path [l]: Path to store the fused monster. + """ + + input_paths: InputPaths + output_path: str + + +@dataclass +class FusionConfig3: + """ + Fusion config with separate input and output paths. + + Attributes: + io_paths: Input and output paths for the fusion. + components: Components to fuse. + alpha: Weighting factor for the first monster. + """ + + io_paths: IOPaths2 + components: list[str] = field(default_factory=lambda: ["fang", "claw"]) + alpha: float = 0.5 + + +@dataclass +class Fuse3: + """ + Top level fusion config. + + Attributes: + cfg: The fusion configuration. + """ + + cfg: FusionConfig3 + + +def test_recursive_dataclass_help_2() -> None: + expected = f"""\ + +Fuse two monsters with polymerization. + +[{TS}]Usage:[/] + fuse.py [{NS} {OS}]--left-path[/] [{VS}][/] [{NS} {OS}]--right-path[/] [{VS}][/] [{NS} {OS}]--output-path[/] [{VS}][/] [[{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/]] [[{NS} {OS}]--alpha[/] [{VS}][/]] + +[{TS}]where[/] + [dim](option)[/] [{NS} {OS}]--left-path[/] [{VS}][/] [i]Path to the first monster.[/] [yellow](required)[/] + [dim](option)[/] [{NS} {OS}]-r[/][{OS} dim]|[/][{NS} {OS}]--right-path[/] [{VS}][/] [i]Path to the second monster.[/] [yellow](required)[/] + [dim](option)[/] [{NS} {OS}]-l[/][{OS} dim]|[/][{NS} {OS}]--output-path[/] [{VS}][/] [i]Path to store the fused monster.[/] [yellow](required)[/] + [dim](option)[/] [{NS} {OS}]-c[/][{OS} dim]|[/][{NS} {OS}]--components[/] [{VS}][/] [dim][[/][{VS} dim][/][dim] ...][/] [i]Components to fuse.[/] [green](default: [/][green]['fang', 'claw'][/][green])[/] + [dim](option)[/] [{NS} {OS}]-a[/][{OS} dim]|[/][{NS} {OS}]--alpha[/] [{VS}][/] [i]Weighting factor for the first monster.[/] [green](default: [/][green]0.5[/][green])[/] + [dim](option)[/] [{NS} {OS} dim]-?[/][{OS} dim]|[/][{NS} {OS} dim]--help[/] [i dim]Show this help message and exit.[/] +""" + check_help_from_class( + Fuse3, + brief="Fuse two monsters with polymerization.", + program_name="fuse.py", + expected=expected, + recurse=True, + ) + + +@dataclass +class FusionConfig4: + """ + Fusion config with separate input and output paths. + + Attributes: + io_paths: Input and output paths for the fusion. + components: Components to fuse. + alpha: Weighting factor for the first monster. + """ + + io_paths: IOPaths2 | tuple[str, str] + components: list[str] = field(default_factory=lambda: ["fang", "claw"]) + alpha: float = 0.5 + + +@dataclass +class Fuse4: + """ + Top level fusion config. + + Attributes: + cfg: The fusion configuration. + """ + + cfg: FusionConfig4 + + +def test_recursive_dataclass_non_class() -> None: + with raises( + ParserConfigError, + match="Cannot recurse into parameter `io_paths` of non-class type `IOPaths2 | tuple[str, str]` in `Fuse4`!", + ): + parse(Fuse4, args=[], recurse=True, naming="nested") + + +@dataclass(kw_only=True) +class AppleConfig: + """ + Configuration for apple. + + Attributes: + color: The color of the apple. + heavy: Whether the apple is heavy. + """ + + color: str = "red" + heavy: bool = False + + +@dataclass(kw_only=True) +class BananaConfig: + """ + Configuration for banana. + + Attributes: + length: The length of the banana. + ripe: Whether the banana is ripe. + """ + + length: float = 6.0 + ripe: bool = False + + +@dataclass +class FruitSaladConfig: + """ + Configuration for fruit salad. + + Attributes: + apple_cfg: Configuration for the apple. + banana_cfg: Configuration for the banana. + servings: Number of servings. + """ + + apple_cfg: AppleConfig + banana_cfg: BananaConfig + servings: int = 1 + + +@mark.parametrize( + "cli_args", + [ + ["--color", "green", "--heavy", "--length", "7.5", "--ripe", "--servings", "3"], + ["--color", "green", "--length", "7.5", "-h", "-r", "-s", "3"], + ["--color", "green", "--length", "7.5", "-hrs", "3"], + ["--color", "green", "--length", "7.5", "-hrs=3"], + ["--color", "green", "--length", "7.5", "-rhs", "3"], + ["--color", "green", "--length", "7.5", "-rhs=3"], + ], +) +def test_combined_short_flags(cli_args: list[str]) -> None: + cfg = parse(FruitSaladConfig, args=cli_args, recurse=True) + assert cfg == FruitSaladConfig( + apple_cfg=AppleConfig(color="green", heavy=True), + banana_cfg=BananaConfig(length=7.5, ripe=True), + servings=3, + ) diff --git a/tests/test_recursive_start.py b/tests/test_recursive_start.py index af212e6..6be5a1f 100644 --- a/tests/test_recursive_start.py +++ b/tests/test_recursive_start.py @@ -809,15 +809,7 @@ def test_recursive_dataclass_help(fuse: Callable[..., Any]) -> None: @mark.parametrize("fuse", [fuse2, fuse2td]) def test_recursive_dataclass_nested(fuse: Callable[..., Any]) -> None: - if fuse is fuse1: - expected = FusionConfig( - left_path="monster1.dat", - right_path="monster2.dat", - output_path="fused_monster.dat", - components=["wing", "tail"], - alpha=0.7, - ) - elif fuse is fuse2: + if fuse is fuse2: expected = FusionConfig2( io_paths=IOPaths( input_paths=InputPaths(