Skip to content

Commit f6029ba

Browse files
committed
Introduce pydantic[1] to validate Oxygen handler result value
[1] https://docs.pydantic.dev/2.0/
1 parent 1abdc0d commit f6029ba

File tree

11 files changed

+443
-2
lines changed

11 files changed

+443
-2
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
robotframework>=3.0.4
22
junitparser==2.0
33
PyYAML>=3.13
4+
pydantic>=2.4.2
45

56
### Dev
67
mock>=2.0.0

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
install_requires=[
3535
'robotframework>=3.0.4',
3636
'junitparser==2.0',
37-
'PyYAML>=3.13'
37+
'PyYAML>=3.13',
38+
'pydantic>=2.4.2'
3839
],
3940
packages=find_packages(SRC),
4041
package_dir={'': 'src'},

src/oxygen/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@ class MismatchArgumentException(Exception):
3232

3333
class InvalidConfigurationException(Exception):
3434
pass
35+
36+
37+
class InvalidOxygenResultException(Exception):
38+
pass

src/oxygen/oxygen_handler_result.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
''' IMPORTANT
2+
3+
OxygenKeywordDict is defined like this since key `pass` is reserved
4+
word in Python, and thus raises SyntaxError if defined like a class.
5+
However, in the functional style you cannot refer to the TypedDict itself
6+
recursively, like you can with with class style. Oh bother.
7+
8+
See more:
9+
- https://docs.python.org/3/library/typing.html?highlight=typeddict#typing.TypedDict
10+
- https://stackoverflow.com/a/72460065
11+
'''
12+
13+
import functools
14+
15+
from typing import List
16+
# TODO FIXME: Python 3.10 requires these to be imported from here
17+
# Python 3.10 EOL is in 2026
18+
from typing_extensions import TypedDict, Required
19+
20+
from pydantic import TypeAdapter, ValidationError
21+
22+
from .errors import InvalidOxygenResultException
23+
24+
25+
_Pass = TypedDict('_Pass', { 'pass': Required[bool], 'name': Required[str] })
26+
# define required fields in this one above
27+
class OxygenKeywordDict(_Pass, total=False):
28+
elapsed: float # milliseconds
29+
tags: List[str]
30+
messages: List[str]
31+
teardown: 'OxygenKeywordDict' # in RF, keywords do not have setup kw; just put it as first kw in `keywords`
32+
keywords: List['OxygenKeywordDict']
33+
34+
35+
class OxygenTestCaseDict(TypedDict, total=False):
36+
name: Required[str]
37+
keywords: Required[List[OxygenKeywordDict]]
38+
tags: List[str]
39+
setup: OxygenKeywordDict
40+
teardown: OxygenKeywordDict
41+
42+
43+
class OxygenSuiteDict(TypedDict, total=False):
44+
name: Required[str]
45+
tags: List[str]
46+
setup: OxygenKeywordDict
47+
teardown: OxygenKeywordDict
48+
suites: List['OxygenSuiteDict']
49+
tests: List[OxygenTestCaseDict]
50+
51+
52+
def _change_validationerror_to_oxygenexception(func):
53+
@functools.wraps(func)
54+
def wrapper(*args, **kwargs):
55+
try:
56+
return func(*args, **kwargs)
57+
except ValidationError as e:
58+
raise InvalidOxygenResultException(e)
59+
return wrapper
60+
61+
@_change_validationerror_to_oxygenexception
62+
def validate_oxygen_suite(oxygen_result_dict):
63+
return TypeAdapter(OxygenSuiteDict).validate_python(oxygen_result_dict)
64+
65+
@_change_validationerror_to_oxygenexception
66+
def validate_oxygen_test_case(oxygen_test_case_dict):
67+
return TypeAdapter(OxygenTestCaseDict).validate_python(oxygen_test_case_dict)
68+
69+
@_change_validationerror_to_oxygenexception
70+
def validate_oxygen_keyword(oxygen_kw_dict):
71+
return TypeAdapter(OxygenKeywordDict).validate_python(oxygen_kw_dict)

tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def install(context, package=None):
3737
'multiple times to select several targets.'
3838
})
3939
def utest(context, test=None):
40-
run(f'pytest {" ".join(test) if test else UNIT_TESTS} -q --disable-warnings',
40+
run(f'pytest {" -k".join(test) if test else UNIT_TESTS} -q --disable-warnings',
4141
env={'PYTHONPATH': str(SRCPATH)},
4242
pty=(not system() == 'Windows'))
4343

tests/utest/helpers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from robot.api import ExecutionResult
99
from yaml import FullLoader, load
1010

11+
from oxygen.oxygen_handler_result import OxygenKeywordDict, OxygenTestCaseDict
12+
1113
TEST_CONFIG = '''
1214
oxygen.junit:
1315
handler: JUnitHandler
@@ -55,6 +57,24 @@ def example_robot_output():
5557
output = RESOURCES_PATH / 'example_robot_output.xml'
5658
return ExecutionResult(output)
5759

60+
MINIMAL_KEYWORD_DICT = { 'name': 'someKeyword', 'pass': True }
61+
MINIMAL_TC_DICT = { 'name': 'Minimal TC', 'keywords': [MINIMAL_KEYWORD_DICT] }
62+
63+
class _ListSubclass(list):
64+
'''Used in test cases'''
65+
pass
66+
67+
68+
class _KwSubclass(OxygenKeywordDict):
69+
'''Used in test cases'''
70+
pass
71+
72+
73+
class _TCSubclass(OxygenTestCaseDict):
74+
'''Used in test cases'''
75+
pass
76+
77+
5878
GATLING_EXPECTED_OUTPUT = {'name': 'Gatling Scenario',
5979
'setup': [],
6080
'suites': [],

tests/utest/oxygen_handler_result/__init__.py

Whitespace-only changes.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from ..helpers import (MINIMAL_KEYWORD_DICT,
2+
_ListSubclass,
3+
_KwSubclass)
4+
5+
class SharedTestsForName(object):
6+
def shared_test_for_name(self):
7+
class StrSubclass(str):
8+
pass
9+
valid_inherited = StrSubclass('someKeyword')
10+
this_is_not_None = StrSubclass(None)
11+
12+
self.valid_inputs_for('name',
13+
'',
14+
'someKeyword',
15+
b'someKeyword',
16+
valid_inherited,
17+
this_is_not_None)
18+
19+
self.invalid_inputs_for('name', None)
20+
21+
22+
class SharedTestsForTags(object):
23+
def shared_test_for_tags(self):
24+
self.valid_inputs_for('tags',
25+
[],
26+
['some-tag', 'another-tag'],
27+
_ListSubclass())
28+
29+
invalid_inherited = _ListSubclass()
30+
invalid_inherited.append(123)
31+
32+
self.invalid_inputs_for('tags', [123], None, {'foo': 'bar'}, object())
33+
34+
35+
class SharedTestsForKeywordField(object):
36+
def shared_test_for_keyword_field(self, attribute):
37+
valid_inherited = _KwSubclass(**MINIMAL_KEYWORD_DICT)
38+
39+
self.valid_inputs_for(attribute,
40+
MINIMAL_KEYWORD_DICT,
41+
valid_inherited,
42+
{**MINIMAL_KEYWORD_DICT,
43+
'something_random': 'will-be-ignored'})
44+
45+
self.invalid_inputs_for(attribute, None, {})
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from unittest import TestCase
2+
3+
from oxygen.errors import InvalidOxygenResultException
4+
from oxygen.oxygen_handler_result import (validate_oxygen_keyword,
5+
OxygenKeywordDict)
6+
7+
from ..helpers import (MINIMAL_KEYWORD_DICT,
8+
_ListSubclass,
9+
_KwSubclass)
10+
from .shared_tests import (SharedTestsForKeywordField,
11+
SharedTestsForName,
12+
SharedTestsForTags)
13+
14+
15+
class TestOxygenKeywordDict(TestCase,
16+
SharedTestsForName,
17+
SharedTestsForTags,
18+
SharedTestsForKeywordField):
19+
def setUp(self):
20+
self.minimal = MINIMAL_KEYWORD_DICT
21+
22+
def test_validate_oxygen_keyword_validates_correctly(self):
23+
with self.assertRaises(InvalidOxygenResultException):
24+
validate_oxygen_keyword({})
25+
26+
def test_validate_oxygen_keyword_with_minimal_valid(self):
27+
minimal1 = { 'name': 'somename', 'pass': True }
28+
minimal2 = { 'name': 'somename', 'pass': False }
29+
30+
self.assertEqual(validate_oxygen_keyword(minimal1), minimal1)
31+
self.assertEqual(validate_oxygen_keyword(minimal2), minimal2)
32+
33+
def valid_inputs_for(self, attribute, *valid_inputs):
34+
for valid_input in valid_inputs:
35+
self.assertTrue(validate_oxygen_keyword({**self.minimal,
36+
attribute: valid_input}))
37+
38+
def invalid_inputs_for(self, attribute, *invalid_inputs):
39+
for invalid_input in invalid_inputs:
40+
with self.assertRaises(InvalidOxygenResultException):
41+
validate_oxygen_keyword({**self.minimal,
42+
attribute: invalid_input})
43+
44+
def test_validate_oxygen_keyword_validates_name(self):
45+
self.shared_test_for_name()
46+
47+
def test_validate_oxygen_keyword_validates_pass(self):
48+
'''
49+
Due note that boolean cannot be subclassed in Python:
50+
https://mail.python.org/pipermail/python-dev/2002-March/020822.html
51+
'''
52+
self.valid_inputs_for('pass', True, False, 0, 1, 0.0, 1.0)
53+
self.invalid_inputs_for('pass', [], {}, None, object(), -999, -99.9)
54+
55+
def test_validate_oxygen_keyword_validates_tags(self):
56+
self.shared_test_for_tags()
57+
58+
def test_validate_oxygen_keyword_validates_elapsed(self):
59+
class FloatSubclass(float):
60+
pass
61+
62+
self.valid_inputs_for('elapsed',
63+
123.4,
64+
-123.0,
65+
'123.4',
66+
'-999.999',
67+
123,
68+
FloatSubclass())
69+
70+
self.invalid_inputs_for('elapsed', '', None, object())
71+
72+
def test_validate_oxygen_keyword_validates_messages(self):
73+
valid_inherited = _ListSubclass()
74+
valid_inherited.append('message')
75+
76+
self.valid_inputs_for('messages',
77+
[],
78+
['message'],
79+
_ListSubclass(),
80+
valid_inherited)
81+
82+
invalid_inherited = _ListSubclass()
83+
invalid_inherited.append('message')
84+
invalid_inherited.append(123)
85+
86+
self.invalid_inputs_for('messages',
87+
'some,messages',
88+
None,
89+
invalid_inherited)
90+
91+
def test_validate_oxygen_keyword_validates_teardown(self):
92+
self.shared_test_for_keyword_field('teardown')
93+
94+
def test_validate_oxygen_keyword_validates_keywords(self):
95+
valid_inherited = _ListSubclass()
96+
valid_inherited.append(_KwSubclass(**self.minimal))
97+
98+
self.valid_inputs_for('keywords',
99+
[],
100+
[self.minimal, {**self.minimal,
101+
'something_random': 'will-be-ignored'}],
102+
_ListSubclass(), # empty inherited list
103+
valid_inherited)
104+
105+
invalid_inherited = _ListSubclass()
106+
invalid_inherited.append(_KwSubclass(**self.minimal))
107+
invalid_inherited.append(123)
108+
self.invalid_inputs_for('keywords', None, invalid_inherited)
109+
110+
def test_validate_oxygen_keyword_with_maximal_valid(self):
111+
expected = {
112+
'name': 'keyword',
113+
'pass': True,
114+
'tags': ['some-tag'],
115+
'messages': ['some message'],
116+
'teardown': {
117+
'name': 'teardownKeyword',
118+
'pass': True,
119+
'tags': ['teardown-kw'],
120+
'messages': ['Teardown passed'],
121+
'keywords': []
122+
},
123+
'keywords': [{
124+
'name': 'subKeyword',
125+
'pass': False,
126+
# tags missing intentionally
127+
'messages': ['This particular kw failed'],
128+
'teardown': {
129+
'name': 'anotherTeardownKw',
130+
'pass': True,
131+
'tags': ['teardown-kw'],
132+
'messages': ['message from anotherTeardownKw'],
133+
# teardown missing intentionally
134+
'keywords': []
135+
},
136+
'keywords': [{
137+
'name': 'subsubKeyword',
138+
'pass': True,
139+
}]
140+
},{
141+
'name': 'anotherSubKeyword',
142+
'pass': True,
143+
'tags': [],
144+
'messages': [],
145+
'keywords': []
146+
}]
147+
}
148+
149+
self.assertEqual(validate_oxygen_keyword(expected), expected)

0 commit comments

Comments
 (0)