Skip to content

Commit 2769575

Browse files
authored
Return type information for Robot Framework type conversion (#22)
Add support for dynamic API get_keyword_types
1 parent b9a287a commit 2769575

File tree

8 files changed

+442
-30
lines changed

8 files changed

+442
-30
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from typing import List, Union, NewType
2+
3+
from robot.api import logger
4+
5+
from robotlibcore import DynamicCore, keyword
6+
7+
UserId = NewType('UserId', int)
8+
9+
10+
class CustomObject(object):
11+
12+
def __init__(self, x, y):
13+
self.x = x
14+
self.y = y
15+
16+
17+
class DynamicTypesAnnotationsLibrary(DynamicCore):
18+
19+
def __init__(self, arg: str):
20+
DynamicCore.__init__(self, [])
21+
self.instance_attribute = 'not keyword'
22+
self.arg = arg
23+
24+
@keyword
25+
def keyword_with_one_annotation(self, arg: str):
26+
return arg
27+
28+
@keyword
29+
def keyword_with_multiple_annotations(self, arg1: str, arg2: List):
30+
return arg1, arg2
31+
32+
@keyword
33+
def keyword_multiple_types(self, arg: Union[List, None]):
34+
return arg
35+
36+
@keyword
37+
def keyword_new_type(self, arg: UserId):
38+
return arg
39+
40+
@keyword
41+
def keyword_define_return_type(self, arg: str) -> None:
42+
logger.info(arg)
43+
return None
44+
45+
@keyword
46+
def keyword_forward_references(self, arg: 'CustomObject'):
47+
return arg
48+
49+
@keyword
50+
def keyword_with_annotations_and_default(self, arg: str = 'Foobar'):
51+
return arg
52+
53+
@keyword
54+
def keyword_with_webdriver(self, arg: CustomObject):
55+
return arg
56+
57+
@keyword
58+
def keyword_default_and_annotation(self, arg1: int, arg2=False) -> str:
59+
return '%s: %s, %s: %s' % (arg1, type(arg1), arg2, type(arg2))
60+
61+
@keyword(types={'arg': str})
62+
def keyword_robot_types_and_annotations(self, arg: int):
63+
return '%s: %s' % (arg, type(arg))
64+
65+
@keyword(types=None)
66+
def keyword_robot_types_disabled_and_annotations(self, arg: int):
67+
return '%s: %s' % (arg, type(arg))
68+
69+
@keyword(types={'arg1': str})
70+
def keyword_robot_types_and_bool_defaults(self, arg1, arg2=False):
71+
return '%s: %s, %s: %s' % (arg1, type(arg1), arg2, type(arg2))
72+
73+
@keyword
74+
def keyword_exception_annotations(self, arg: 'NotHere'):
75+
return arg

atest/DynamicTypesLibrary.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import sys
2+
3+
from robotlibcore import DynamicCore, keyword
4+
5+
6+
class DynamicTypesLibrary(DynamicCore):
7+
8+
def __init__(self, arg=False):
9+
DynamicCore.__init__(self, [])
10+
self.instance_attribute = 'not keyword'
11+
self.arg = arg
12+
13+
@keyword(types={'arg1': str})
14+
def keyword_with_types(self, arg1):
15+
return arg1
16+
17+
@keyword(types={'arg1': str})
18+
def keyword_robot_types_and_bool_default(self, arg1, arg2=False):
19+
return arg1, arg2
20+
21+
@keyword(types=None)
22+
def keyword_with_disabled_types(self, arg1):
23+
return arg1
24+
25+
@keyword(types={'arg1': str})
26+
def keyword_with_one_type(self, arg1, arg2):
27+
return arg1, arg2
28+
29+
@keyword
30+
def keyword_with_no_args(self):
31+
return False
32+
33+
def not_keyword(self):
34+
return False
35+
36+
@keyword
37+
def keyword_default_types(self, arg=None):
38+
return arg
39+
40+
@keyword
41+
def keyword_many_default_types(self, arg1=1, arg2='Foobar'):
42+
return arg1, arg2
43+
44+
@keyword
45+
def keyword_booleans(self, arg1=True, arg2=False):
46+
return '%s: %s, %s: %s' % (arg1, type(arg1), arg2, type(arg2))
47+
48+
@keyword
49+
def keyword_none(self, arg=None):
50+
return '%s: %s' % (arg, type(arg))
51+
52+
@keyword
53+
def is_python_3(self):
54+
return sys.version_info >= (3,)

atest/run.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,24 @@
1515
curdir = dirname(abspath(__file__))
1616
outdir = join(curdir, 'results')
1717
tests = join(curdir, 'tests.robot')
18+
tests_types = join(curdir, 'tests_types.robot')
1819
sys.path.insert(0, join(curdir, '..', 'src'))
1920
python_version = platform.python_version()
2021
for variant in library_variants:
2122
output = join(outdir, 'lib-%s-python-%s-robot-%s.xml' % (variant, python_version, rf_version))
2223
rc = run(tests, name=variant, variable='LIBRARY:%sLibrary' % variant,
23-
output=output, report=None, log=None)
24+
output=output, report=None, log=None, loglevel='debug')
2425
if rc > 250:
2526
sys.exit(rc)
2627
process_output(output, verbose=False)
28+
output = join(outdir, 'lib-DynamicTypesLibrary-python-%s-robot-%s.xml' % (python_version, rf_version))
29+
exclude = 'py3' if sys.version_info < (3,) else ''
30+
rc = run(tests_types, name='Types', output=output, report=None, log=None, loglevel='debug', exclude=exclude)
31+
if rc > 250:
32+
sys.exit(rc)
33+
process_output(output, verbose=False)
2734
print('\nCombining results.')
35+
library_variants.append('DynamicTypesLibrary')
2836
rc = rebot(*(join(outdir, 'lib-%s-python-%s-robot-%s.xml' % (variant, python_version, rf_version)) for variant in library_variants),
2937
**dict(name='Acceptance Tests', outputdir=outdir, log='log-python-%s-robot-%s.html' % (python_version, rf_version),
3038
report='report-python-%s-robot-%s.html' % (python_version, rf_version)))

atest/tests_types.robot

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
*** Settings ***
2+
Library DynamicTypesLibrary.py
3+
Suite Setup Import DynamicTypesAnnotationsLibrary In Python 3 Only
4+
5+
*** Test Cases ***
6+
Keyword Default Argument As Abject None
7+
${return} = DynamicTypesLibrary.Keyword None ${None}
8+
Should Match Regexp ${return} None: <(class|type) 'NoneType'>
9+
10+
Keyword Default Argument As Abject None Default Value
11+
${return} = DynamicTypesLibrary.Keyword None
12+
Should Match Regexp ${return} None: <(class|type) 'NoneType'>
13+
14+
Keyword Default Argument As String None
15+
${return} = DynamicTypesLibrary.Keyword None None
16+
Should Match Regexp ${return} None: <(class|type) 'NoneType'>
17+
18+
Keyword Default As Booleans With Defaults
19+
${return} DynamicTypesLibrary.Keyword Booleans
20+
Should Match Regexp ${return} True: <(class|type) 'bool'>, False: <(class|type) 'bool'>
21+
22+
Keyword Default As Booleans With Strings
23+
${return} = DynamicTypesLibrary.Keyword Booleans False True
24+
Should Match Regexp ${return} False: <(class|type) 'bool'>, True: <(class|type) 'bool'>
25+
26+
Keyword Default As Booleans With Objects
27+
${return} = DynamicTypesLibrary.Keyword Booleans ${False} ${True}
28+
Should Match Regexp ${return} False: <(class|type) 'bool'>, True: <(class|type) 'bool'>
29+
30+
Keyword Annonations And Bool Defaults Using Default
31+
[Tags] py3
32+
${return} = DynamicTypesAnnotationsLibrary.Keyword Default And Annotation 42
33+
Should Match Regexp ${return} 42: <(class|type) 'int'>, False: <(class|type) 'bool'>
34+
35+
Keyword Annonations And Bool Defaults Defining All Arguments
36+
[Tags] py3
37+
${return} = DynamicTypesAnnotationsLibrary.Keyword Default And Annotation 1 true
38+
Should Match Regexp ${return} 1: <(class|type) 'int'>, True: <(class|type) 'bool'>
39+
40+
Keyword Annonations And Bool Defaults Defining All Arguments And With Number
41+
[Tags] py3
42+
${return} = DynamicTypesAnnotationsLibrary.Keyword Default And Annotation ${1} true
43+
Should Match Regexp ${return} 1: <(class|type) 'int'>, True: <(class|type) 'bool'>
44+
45+
Keyword Annonations And Robot Types Disbales Argument Conversion
46+
[Tags] py3
47+
${return} = DynamicTypesAnnotationsLibrary.Keyword Robot Types Disabled And Annotations 111
48+
Should Match Regexp ${return} 111: <(class|type) 'str'>
49+
50+
51+
Keyword Annonations And Robot Types Defined
52+
[Tags] py3
53+
${return} = DynamicTypesAnnotationsLibrary.Keyword Robot Types And Bool Defaults tidii 111
54+
Should Match Regexp ${return} tidii: <(class|type) 'str'>, 111: <(class|type) 'str'>
55+
56+
*** Keywords ***
57+
Import DynamicTypesAnnotationsLibrary In Python 3 Only
58+
${py3} = DynamicTypesLibrary.Is Python 3
59+
Run Keyword If ${py3}
60+
... Import Library DynamicTypesAnnotationsLibrary.py Dummy

src/robotlibcore.py

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,13 @@
2121

2222
import inspect
2323
import sys
24-
2524
try:
26-
from robot.api.deco import keyword
27-
except ImportError: # Support RF < 2.9
28-
def keyword(name=None, tags=()):
29-
if callable(name):
30-
return keyword()(name)
25+
import typing
26+
except ImportError:
27+
typing = None
3128

32-
def decorator(func):
33-
func.robot_name = name
34-
func.robot_tags = tags
35-
return func
36-
return decorator
3729

30+
from robot.api.deco import keyword # noqa F401
3831

3932
PY2 = sys.version_info < (3,)
4033

@@ -51,7 +44,7 @@ def __init__(self, library_components):
5144

5245
def add_library_components(self, library_components):
5346
for component in library_components:
54-
for name, func in self._get_members(component):
47+
for name, func in self.__get_members(component):
5548
if callable(func) and hasattr(func, 'robot_name'):
5649
kw = getattr(component, name)
5750
kw_name = func.robot_name or name
@@ -60,7 +53,7 @@ def add_library_components(self, library_components):
6053
# method names as well as possible custom names.
6154
self.attributes[name] = self.attributes[kw_name] = kw
6255

63-
def _get_members(self, component):
56+
def __get_members(self, component):
6457
if inspect.ismodule(component):
6558
return inspect.getmembers(component)
6659
if inspect.isclass(component):
@@ -70,9 +63,9 @@ def _get_members(self, component):
7063
raise TypeError('Libraries must be modules or new-style class '
7164
'instances, got old-style class {!r} instead.'
7265
.format(component.__class__.__name__))
73-
return self._get_members_from_instance(component)
66+
return self.__get_members_from_instance(component)
7467

75-
def _get_members_from_instance(self, instance):
68+
def __get_members_from_instance(self, instance):
7669
# Avoid calling properties by getting members from class, not instance.
7770
cls = type(instance)
7871
for name in dir(instance):
@@ -97,22 +90,22 @@ def get_keyword_names(self):
9790

9891

9992
class DynamicCore(HybridCore):
100-
_get_keyword_tags_supported = False # get_keyword_tags is new in RF 3.0.2
93+
__get_keyword_tags_supported = False # get_keyword_tags is new in RF 3.0.2
10194

10295
def run_keyword(self, name, args, kwargs=None):
10396
return self.keywords[name](*args, **(kwargs or {}))
10497

10598
def get_keyword_arguments(self, name):
10699
kw = self.keywords[name] if name != '__init__' else self.__init__
107-
args, defaults, varargs, kwargs = self._get_arg_spec(kw)
100+
args, defaults, varargs, kwargs = self.__get_arg_spec(kw)
108101
args += ['{}={}'.format(name, value) for name, value in defaults]
109102
if varargs:
110103
args.append('*{}'.format(varargs))
111104
if kwargs:
112105
args.append('**{}'.format(kwargs))
113106
return args
114107

115-
def _get_arg_spec(self, kw):
108+
def __get_arg_spec(self, kw):
116109
if PY2:
117110
spec = inspect.getargspec(kw)
118111
keywords = spec.keywords
@@ -127,7 +120,7 @@ def _get_arg_spec(self, kw):
127120
return mandatory, defaults, spec.varargs, keywords
128121

129122
def get_keyword_tags(self, name):
130-
self._get_keyword_tags_supported = True
123+
self.__get_keyword_tags_supported = True
131124
return self.keywords[name].robot_tags
132125

133126
def get_keyword_documentation(self, name):
@@ -137,11 +130,50 @@ def get_keyword_documentation(self, name):
137130
return inspect.getdoc(self.__init__) or ''
138131
kw = self.keywords[name]
139132
doc = inspect.getdoc(kw) or ''
140-
if kw.robot_tags and not self._get_keyword_tags_supported:
133+
if kw.robot_tags and not self.__get_keyword_tags_supported:
141134
tags = 'Tags: {}'.format(', '.join(kw.robot_tags))
142135
doc = '{}\n\n{}'.format(doc, tags) if doc else tags
143136
return doc
144137

138+
def get_keyword_types(self, keyword_name):
139+
method = self.__get_keyword(keyword_name)
140+
if method == {}:
141+
return method
142+
types = getattr(method, 'robot_types', ())
143+
if types is None:
144+
return types
145+
if not types:
146+
types = self.__get_typing_hints(method)
147+
types = self.__join_defaults_with_types(method, types)
148+
return types
149+
150+
def __get_keyword(self, keyword_name):
151+
if keyword_name == '__init__':
152+
return self.__init__
153+
if keyword_name.startswith('__') and keyword_name.endswith('__'):
154+
return {}
155+
method = self.keywords.get(keyword_name)
156+
if not method:
157+
raise ValueError('Keyword "%s" not found.' % keyword_name)
158+
return method
159+
160+
def __get_typing_hints(self, method):
161+
if PY2:
162+
return {}
163+
try:
164+
hints = typing.get_type_hints(method)
165+
except Exception:
166+
hints = method.__annotations__
167+
hints.pop('return', None)
168+
return hints
169+
170+
def __join_defaults_with_types(self, method, types):
171+
_, defaults, _, _ = self.__get_arg_spec(method)
172+
for name, value in defaults:
173+
if name not in types and isinstance(value, (bool, type(None))):
174+
types[name] = type(value)
175+
return types
176+
145177

146178
class StaticCore(HybridCore):
147179

utest/run.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env python
2+
import argparse
23
import platform
34
from os.path import abspath, dirname, join
45
import sys
@@ -13,12 +14,20 @@
1314
src = join(curdir, '..', 'src')
1415
sys.path.insert(0, src)
1516
sys.path.insert(0, atest_dir)
16-
pytest_args = sys.argv[1:] + [
17+
18+
parser = argparse.ArgumentParser()
19+
parser.add_argument('--no-cov', dest='cov', action='store_false')
20+
parser.add_argument('--cov', dest='cov', action='store_true')
21+
parser.set_defaults(cov=True)
22+
args = parser.parse_args()
23+
24+
pytest_args = [
1725
'-p', 'no:cacheprovider',
1826
'--junitxml=%s' % xunit_report,
1927
'-o', 'junit_family=xunit2',
20-
'--cov=%s' % src,
2128
curdir
2229
]
30+
if args.cov:
31+
pytest_args.insert(0, '--cov=%s' % src)
2332
rc = pytest.main(pytest_args)
2433
sys.exit(rc)

0 commit comments

Comments
 (0)