From e28b44cae1709b0c5729185e1239cc6abf59d7ae Mon Sep 17 00:00:00 2001 From: Thor Whalen Date: Tue, 23 Jan 2024 12:49:05 +0100 Subject: [PATCH] feat: test_that_issue_216_does_not_happen to increase test coverage of #216 --- tests/test_typing_hints.py | 147 +++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/tests/test_typing_hints.py b/tests/test_typing_hints.py index 2432824..f6de66b 100644 --- a/tests/test_typing_hints.py +++ b/tests/test_typing_hints.py @@ -59,3 +59,150 @@ def test_unusable_types(arg_type): guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params assert guess(arg_type) == {} + + +# ------------------------------------------------------------------------------ +# Test type hints on combinations of generics + +from typing import ( + Dict, Tuple, Mapping, MutableMapping, DefaultDict, ChainMap, OrderedDict, + Callable, Optional, List +) + +DFLT_MULTI_PARAM_TYPES = ( + Dict, Tuple, Mapping, MutableMapping, DefaultDict, ChainMap, OrderedDict +) + +def type_combos( + generic_types, + type_variables=None, + *, + multi_param_types=DFLT_MULTI_PARAM_TYPES + ): + """ + Generate "generic" using combinations of types such as + `Optional[List], Dict[Tuple, List], Callable[[List], Dict]` + from a list of generic types such as `Optional`, `List`, `Dict`, `Callable` + and a list of type variables that are used to parametrize these generic types. + + :param generic_types: A list of generic types + :param type_variables: A list of type variables + :return: A generator that yields generic types + + >>> from typing import Optional, Dict, Tuple, List, Callable + >>> list(type_combos([Optional, Tuple], [list, dict])) # doctest: +NORMALIZE_WHITESPACE + [typing.Optional[list], typing.Optional[dict], + typing.Tuple[list, dict], typing.Tuple[dict, list]] + + More significant example: + + >>> generic_types = [Optional, Callable, Dict, Tuple] + >>> type_variables = [tuple, dict, List] + >>> + >>> for combo in type_combos(generic_types, type_variables): + ... print(combo) + typing.Optional[tuple] + typing.Optional[dict] + typing.Optional[typing.List] + typing.Callable[[typing.Tuple[dict, ...]], tuple] + typing.Callable[[typing.Tuple[typing.List, ...]], tuple] + typing.Callable[[typing.Tuple[dict, typing.List]], tuple] + typing.Callable[[typing.Tuple[typing.List, dict]], tuple] + typing.Callable[[typing.Tuple[tuple, ...]], dict] + typing.Callable[[typing.Tuple[typing.List, ...]], dict] + typing.Callable[[typing.Tuple[tuple, typing.List]], dict] + typing.Callable[[typing.Tuple[typing.List, tuple]], dict] + typing.Callable[[typing.Tuple[tuple, ...]], typing.List] + typing.Callable[[typing.Tuple[dict, ...]], typing.List] + typing.Callable[[typing.Tuple[tuple, dict]], typing.List] + typing.Callable[[typing.Tuple[dict, tuple]], typing.List] + typing.Dict[tuple, dict] + typing.Dict[tuple, typing.List] + typing.Dict[dict, tuple] + typing.Dict[dict, typing.List] + typing.Dict[typing.List, tuple] + typing.Dict[typing.List, dict] + typing.Tuple[tuple, dict] + typing.Tuple[tuple, typing.List] + typing.Tuple[dict, tuple] + typing.Tuple[dict, typing.List] + typing.Tuple[typing.List, tuple] + typing.Tuple[typing.List, dict] + """ + from itertools import permutations + + if type_variables is None: + type_variables = list(generic_types) + + def generate_combos(generic_type, remaining_vars): + if generic_type is Callable: + # Separate one variable for the output type + for output_type in remaining_vars: + input_vars = [var for var in remaining_vars if var != output_type] + # Generate combinations of input types + for n in range(1, len(input_vars) + 1): + for input_combo in permutations(input_vars, n): + # Format single-element tuples correctly + if len(input_combo) == 1: + input_type = Tuple[input_combo[0], ...] + else: + input_type = Tuple[input_combo] + yield Callable[[input_type], output_type] + elif generic_type in multi_param_types: + required_params = 2 # These types generally require two type parameters + for combo in permutations(remaining_vars, required_params): + yield generic_type[combo] + else: + for type_var in remaining_vars: + yield generic_type[type_var] + for generic_type in generic_types: + yield from generate_combos(generic_type, type_variables) + + +def issue_216_happens_annotations(func, annotation): + """ + Util to test what annotations make the + https://github.com/neithere/argh/issues/216 + issue happen + """ + import argh + func.__annotations__['x'] = annotation + try: + argh.dispatch_command(func) + except IndexError as e: + if e.args[0] == 'tuple index out of range': + return True + except BaseException: + pass + return False + + +# TODO: Use pytest.mark.parametrize? +def test_that_issue_216_does_not_happen( + generic_types=(Dict, Tuple, OrderedDict, Callable, Optional, List), + type_variables=None + ): + """ + Test that the issue 216 happens with the annotations + that we expect it to happen. + + NOTE: This takes ~18s to run on my side. + Could reduce the number of generic_types and type_variables to accelerate. + (The current settings lead to 109_776 combinations being tested. + """ + from functools import partial + + if type_variables is None: + type_variables = list(set(generic_types) - {Optional}) + [int, str, float] + + combos = list(type_combos(generic_types, type_variables)) + + def func(x = None): + return None + + there_is_an_issue = partial(issue_216_happens_annotations, func) + + failed = list(map(there_is_an_issue, combos)) + + failed_combos = [typ for typ, failed_ in zip(combos, failed) if failed_] + assert not failed_combos, f"There were some failed type combos: {failed_combos=}" \ No newline at end of file