diff --git a/requirements.txt b/requirements.txt index da6a5c6..f8f313b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ robotframework>=3.0.4 junitparser==2.0 PyYAML>=3.13 +pydantic>=2.4.2 ### Dev mock>=2.0.0 diff --git a/setup.py b/setup.py index ebca99c..efb4263 100644 --- a/setup.py +++ b/setup.py @@ -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'}, diff --git a/src/oxygen/errors.py b/src/oxygen/errors.py index 4134f13..933ac85 100644 --- a/src/oxygen/errors.py +++ b/src/oxygen/errors.py @@ -32,3 +32,7 @@ class MismatchArgumentException(Exception): class InvalidConfigurationException(Exception): pass + + +class InvalidOxygenResultException(Exception): + pass diff --git a/src/oxygen/oxygen_handler_result.py b/src/oxygen/oxygen_handler_result.py new file mode 100644 index 0000000..c87c132 --- /dev/null +++ b/src/oxygen/oxygen_handler_result.py @@ -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) diff --git a/tasks.py b/tasks.py index d2d54f6..4dddc97 100644 --- a/tasks.py +++ b/tasks.py @@ -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')) diff --git a/tests/utest/oxygen_handler_result/test_OxygenKeywordDict.py b/tests/utest/oxygen_handler_result/test_OxygenKeywordDict.py new file mode 100644 index 0000000..3b72cbd --- /dev/null +++ b/tests/utest/oxygen_handler_result/test_OxygenKeywordDict.py @@ -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)