Skip to content

Commit

Permalink
Introduce pydantic to validate Oxygen handler result value
Browse files Browse the repository at this point in the history
  • Loading branch information
Tattoo committed Oct 23, 2023
1 parent 1abdc0d commit 6af0411
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 2 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
robotframework>=3.0.4
junitparser==2.0
PyYAML>=3.13
pydantic>=2.4.2

### Dev
mock>=2.0.0
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
install_requires=[
'robotframework>=3.0.4',
'junitparser==2.0',
'PyYAML>=3.13'
'PyYAML>=3.13',
'pydantic>=2.4.2'
],
packages=find_packages(SRC),
package_dir={'': 'src'},
Expand Down
4 changes: 4 additions & 0 deletions src/oxygen/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ class MismatchArgumentException(Exception):

class InvalidConfigurationException(Exception):
pass


class InvalidOxygenResultException(Exception):
pass
65 changes: 65 additions & 0 deletions src/oxygen/oxygen_handler_result.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import functools

from typing import List, Optional
from typing_extensions import TypedDict

from pydantic import TypeAdapter, ValidationError

from .errors import InvalidOxygenResultException

# OxygenKeywordDict is defined like this since key `pass` is reserved
# word in Python, and thus raises SyntaxError if defined like a class.
# However, in the functional style you cannot refer to the TypedDict itself,
# like you can with with class style. Oh bother.
#
# See more:
# - https://docs.python.org/3/library/typing.html?highlight=typeddict#typing.TypedDict
# - https://stackoverflow.com/a/72460065

_Pass = TypedDict('_Pass', { 'pass': bool })
class OxygenKeywordDict(_Pass, total=False):
name: str
elapsed: Optional[float] # milliseconds
tags: Optional[List[str]]
messages: Optional[List[str]]
teardown: Optional['OxygenKeywordDict'] # in RF, keywords do not have setup kw; just put it as first kw in `keywords`
keywords: Optional[List['OxygenKeywordDict']]


class OxygenTestCaseDict(TypedDict, total=False):
name: str
tags: List[str]
setup: OxygenKeywordDict
teardown: OxygenKeywordDict
keywords: List[OxygenKeywordDict]


class OxygenSuiteDict(TypedDict, total=False):
name: str
tags: List[str]
setup: OxygenKeywordDict
teardown: OxygenKeywordDict
suites: List['OxygenSuiteDict']
tests: List[OxygenTestCaseDict]


def _change_validationerror_to_oxygenexception(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValidationError as e:
raise InvalidOxygenResultException(e)
return wrapper

@_change_validationerror_to_oxygenexception
def validate_oxygen_suite(oxygen_result_dict):
return TypeAdapter(OxygenSuiteDict).validate_python(oxygen_result_dict)

@_change_validationerror_to_oxygenexception
def validate_oxygen_test_case(oxygen_test_case_dict):
return TypeAdapter(OxygenTestCaseDict).validate_python(oxygen_test_case_dict)

@_change_validationerror_to_oxygenexception
def validate_oxygen_keyword(oxygen_kw_dict):
return TypeAdapter(OxygenKeywordDict).validate_python(oxygen_kw_dict)
2 changes: 1 addition & 1 deletion tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def install(context, package=None):
'multiple times to select several targets.'
})
def utest(context, test=None):
run(f'pytest {" ".join(test) if test else UNIT_TESTS} -q --disable-warnings',
run(f'pytest {" -k".join(test) if test else UNIT_TESTS} -q --disable-warnings',
env={'PYTHONPATH': str(SRCPATH)},
pty=(not system() == 'Windows'))

Expand Down
181 changes: 181 additions & 0 deletions tests/utest/oxygen_handler_result/test_OxygenKeywordDict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
from unittest import TestCase

from oxygen.errors import InvalidOxygenResultException
from oxygen.oxygen_handler_result import (validate_oxygen_keyword,
OxygenKeywordDict)


class _ListSubclass(list):
'''Used in test cases'''
pass


class _KwSubclass(OxygenKeywordDict):
'''Used in test cases'''
pass


class TestOxygenKeywordDict(TestCase):
def setUp(self):
self.minimal = { 'name': 'someKeyword', 'pass': True }

def test_validate_oxygen_keyword_validates_correctly(self):
with self.assertRaises(InvalidOxygenResultException):
validate_oxygen_keyword({})

def test_validate_oxygen_keyword_with_minimal_valid(self):
minimal1 = { 'name': 'somename', 'pass': True }
minimal2 = { 'name': 'somename', 'pass': False }

self.assertEqual(validate_oxygen_keyword(minimal1), minimal1)
self.assertEqual(validate_oxygen_keyword(minimal2), minimal2)

def valid_inputs_for(self, attribute, *valid_inputs):
for valid_input in valid_inputs:
self.assertTrue(validate_oxygen_keyword({**self.minimal,
attribute: valid_input}))

def invalid_inputs_for(self, attribte, *invalid_inputs):
for invalid_input in invalid_inputs:
with self.assertRaises(InvalidOxygenResultException):
validate_oxygen_keyword({**self.minimal,
attribte: invalid_input})

def test_validate_oxygen_keyword_validates_name(self):
class StrSubclass(str):
pass
valid_inherited = StrSubclass('someKeyword')
this_is_not_None = StrSubclass(None)

self.valid_inputs_for('name',
'',
'someKeyword',
b'someKeyword',
valid_inherited,
this_is_not_None)

self.invalid_inputs_for('name', None)

def test_validate_oxygen_keyword_validates_pass(self):
'''
Due note that boolean cannot be subclassed in Python:
https://mail.python.org/pipermail/python-dev/2002-March/020822.html
'''
self.valid_inputs_for('pass', True, False, 0, 1, 0.0, 1.0)
self.invalid_inputs_for('pass', [], {}, None, object(), -999, -99.9)

def test_validate_oxygen_keyword_validates_tags(self):
self.valid_inputs_for('tags',
[],
['some-tag', 'another-tag'],
None,
_ListSubclass())

invalid_inherited = _ListSubclass()
invalid_inherited.append(123)

self.invalid_inputs_for('tags', [123], {'foo': 'bar'}, object())

def test_validate_oxygen_keyword_validates_elapsed(self):
class FloatSubclass(float):
pass

self.valid_inputs_for('elapsed',
123.4,
-123.0,
'123.4',
'-999.999',
123,
None,
FloatSubclass())

self.invalid_inputs_for('elapsed', '', object())

def test_validate_oxygen_keyword_validates_messages(self):
valid_inherited = _ListSubclass()
valid_inherited.append('message')

self.valid_inputs_for('messages',
[],
['message'],
None,
_ListSubclass(),
valid_inherited)

invalid_inherited = _ListSubclass()
invalid_inherited.append('message')
invalid_inherited.append(123)

self.invalid_inputs_for('messages', 'some,messages', invalid_inherited)

def test_validate_oxygen_keyword_validates_teardown(self):
valid_inherited = _KwSubclass(**self.minimal)

self.valid_inputs_for('teardown',
None,
self.minimal,
valid_inherited,
{**self.minimal,
'something_random': 'will-be-ignored'})

self.invalid_inputs_for('teardown', {})

def test_validate_oxygen_keyword_validates_keywords(self):
valid_inherited = _ListSubclass()
valid_inherited.append(_KwSubclass(**self.minimal))

self.valid_inputs_for('keywords',
None,
[],
[self.minimal, {**self.minimal,
'something_random': 'will-be-ignored'}],
_ListSubclass(), # empty inherited list
valid_inherited)

invalid_inherited = _ListSubclass()
invalid_inherited.append(_KwSubclass(**self.minimal))
invalid_inherited.append(123)
self.invalid_inputs_for('keywords', invalid_inherited)

def test_validate_oxygen_keyword_with_maximal_valid(self):
expected = {
'name': 'keyword',
'pass': True,
'tags': ['some-tag'],
'messages': ['some message'],
'teardown': {
'name': 'teardownKeyword',
'pass': True,
'tags': ['teardown-kw'],
'messages': ['Teardown passed'],
'teardown': None,
'keywords': []
},
'keywords': [{
'name': 'subKeyword',
'pass': False,
# tags missing intentionally
'messages': ['This particular kw failed'],
'teardown': {
'name': 'anotherTeardownKw',
'pass': True,
'tags': ['teardown-kw'],
'messages': ['message from anotherTeardownKw'],
# teardown missing intentionally
'keywords': []
},
'keywords': [{
'name': 'subsubKeyword',
'pass': True
}]
},{
'name': 'anotherSubKeyword',
'pass': True,
'tags': [],
'messages': [],
'teardown': None,
'keywords': []
}]
}

self.assertEqual(validate_oxygen_keyword(expected), expected)

0 comments on commit 6af0411

Please sign in to comment.