diff --git a/atest/DynamicTypesAnnotationsLibrary.py b/atest/DynamicTypesAnnotationsLibrary.py index 7178527..a53edd2 100644 --- a/atest/DynamicTypesAnnotationsLibrary.py +++ b/atest/DynamicTypesAnnotationsLibrary.py @@ -1,4 +1,5 @@ -from typing import List, Union, NewType, Dict +from enum import Enum +from typing import List, Union, NewType, Optional from robot.api import logger @@ -6,6 +7,8 @@ UserId = NewType('UserId', int) +penum = Enum("penum", "ok") + class CustomObject(object): @@ -117,3 +120,9 @@ def keyword_self_and_keyword_only_types(x: 'DynamicTypesAnnotationsLibrary', man **kwargs: int): return (f'{mandatory}: {type(mandatory)}, {varargs}: {type(varargs)}, ' f'{other}: {type(other)}, {kwargs}: {type(kwargs)}') + + @keyword + def enum_conversion(self, param: Optional[penum] = None): + logger.info(f'OK {param}') + logger.info(param.ok) + return f'OK {param}' diff --git a/atest/moc_library_py3.py b/atest/moc_library_py3.py index 81f729d..c9444ed 100644 --- a/atest/moc_library_py3.py +++ b/atest/moc_library_py3.py @@ -1,3 +1,6 @@ +from typing import Optional + + class MockLibraryPy3: def named_only(self, *varargs, key1, key2): @@ -11,3 +14,6 @@ def args_with_type_hints(self, arg1, arg2, arg3: str, arg4: None) -> bool: def self_and_keyword_only_types(x: 'MockLibraryPy3', mandatory, *varargs: int, other: bool, **kwargs: int): pass + + def optional_none(self, xxx, arg1: Optional[str] = None, arg2: Optional[str] = None, arg3=False): + pass diff --git a/atest/tests_types.robot b/atest/tests_types.robot index 83e94c8..139d983 100644 --- a/atest/tests_types.robot +++ b/atest/tests_types.robot @@ -63,6 +63,17 @@ Varargs and KeywordArgs With Typing Hints Should Match ${return} ... this_is_mandatory: , (1, 2, 3, 4): , True: , {'key1': 1, 'key2': 2}: +Enum Conversion Should Work + [Tags] py3 + ${value} = Enum Conversion ok + Should Match OK penum.ok ${value} + +Enum Conversion To Invalid Value Should Fail + [Tags] py3 + Run Keyword And Expect Error ValueError: Argument 'param' got value 'not ok' that* + ... Enum Conversion not ok + + *** Keywords *** Import DynamicTypesAnnotationsLibrary In Python 3 Only ${py3} = DynamicTypesLibrary.Is Python 3 diff --git a/src/robotlibcore.py b/src/robotlibcore.py index c932f4d..3897536 100644 --- a/src/robotlibcore.py +++ b/src/robotlibcore.py @@ -23,6 +23,8 @@ import os import sys +from robot.utils import PY_VERSION + try: import typing except ImportError: @@ -245,9 +247,7 @@ def _get_types(cls, function): types = getattr(function, 'robot_types', ()) if types is None or types: return types - if not types: - types = cls._get_typing_hints(function) - return types + return cls._get_typing_hints(function) @classmethod def _get_typing_hints(cls, function): @@ -257,16 +257,17 @@ def _get_typing_hints(cls, function): hints = typing.get_type_hints(function) except Exception: hints = function.__annotations__ - all_args = cls._args_as_list(function) + arg_spec = cls._get_arg_spec(function) + all_args = cls._args_as_list(function, arg_spec) for arg_with_hint in list(hints): # remove return and self statements if arg_with_hint not in all_args: hints.pop(arg_with_hint) - return hints + default = cls._get_defaults(arg_spec) + return cls._remove_optional_none_type_hints(hints, default) @classmethod - def _args_as_list(cls, function): - arg_spec = cls._get_arg_spec(function) + def _args_as_list(cls, function, arg_spec): function_args = [] function_args.extend(cls._drop_self_from_args(function, arg_spec)) if arg_spec.varargs: @@ -276,6 +277,34 @@ def _args_as_list(cls, function): function_args.append(arg_spec.varkw) return function_args + # Copied from: robot.running.arguments.argumentparser + @classmethod + def _remove_optional_none_type_hints(cls, type_hints, defaults): + # If argument has None as a default, typing.get_type_hints adds + # optional None to the information it returns. We don't want that. + for arg, default in defaults: + if default is None and arg in type_hints: + type_ = type_hints[arg] + if cls._is_union(type_): + types = type_.__args__ + if len(types) == 2 and types[1] is type(None): # noqa + type_hints[arg] = types[0] + return type_hints + + # Copied from: robot.running.arguments.argumentparser + @classmethod + def _is_union(cls, typing_type): + if PY_VERSION >= (3, 7) and hasattr(typing_type, '__origin__'): + typing_type = typing_type.__origin__ + return isinstance(typing_type, type(typing.Union)) + + @classmethod + def _get_defaults(cls, arg_spec): + if not arg_spec.defaults: + return {} + names = arg_spec.args[-len(arg_spec.defaults):] + return zip(names, arg_spec.defaults) + class KeywordSpecification(object): diff --git a/utest/test_keyword_builder.py b/utest/test_keyword_builder.py index 809b29f..eb58c4a 100644 --- a/utest/test_keyword_builder.py +++ b/utest/test_keyword_builder.py @@ -3,6 +3,7 @@ from robotlibcore import PY2, RF31, KeywordBuilder from moc_library import MockLibrary if not PY2: + from typing import Union from moc_library_py3 import MockLibraryPy3 @@ -99,6 +100,12 @@ def test_types_(lib_py3): @pytest.mark.skipif(PY2, reason='Only for Python 3') -def test_types_(lib_py3): +def test_types(lib_py3): spec = KeywordBuilder.build(lib_py3.self_and_keyword_only_types) assert spec.argument_types == {'varargs': int, 'other': bool, 'kwargs': int} + + +@pytest.mark.skipif(PY2, reason='Only for Python 3') +def test_optional_none(lib_py3): + spec = KeywordBuilder.build(lib_py3.optional_none) + assert spec.argument_types == {'arg1': str, 'arg2': str}