From e4cd14f27845c80daa5f67585369ec573a0ea42f Mon Sep 17 00:00:00 2001 From: Tattoo Date: Wed, 11 Oct 2023 13:44:50 +0300 Subject: [PATCH 1/8] Sketch possible solution for enhaning Oxygen handler configuration --- src/oxygen/config.py | 5 +- src/oxygen/config.yml | 1 + src/oxygen/errors.py | 4 ++ src/oxygen/oxygen.py | 87 ++++++++++++++++++++++----- tests/utest/oxygen/test_oxygen_cli.py | 12 +++- 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/src/oxygen/config.py b/src/oxygen/config.py index fdff003..0f342af 100644 --- a/src/oxygen/config.py +++ b/src/oxygen/config.py @@ -1,3 +1,4 @@ -from os.path import abspath, dirname, join +from pathlib import Path -CONFIG_FILE = join(abspath(dirname(__file__)), 'config.yml') +CONFIG_FILE = Path(__file__).resolve().parent / 'config.yml' +ORIGINAL_CONFIG_FILE = Path(__file__).resolve().parent / 'original_config.yml' diff --git a/src/oxygen/config.yml b/src/oxygen/config.yml index b5c62df..481ab62 100644 --- a/src/oxygen/config.yml +++ b/src/oxygen/config.yml @@ -13,3 +13,4 @@ oxygen.zap: tags: oxygen-zap accepted_risk_level: 2 required_confidence_level: 1 + diff --git a/src/oxygen/errors.py b/src/oxygen/errors.py index 932a080..4134f13 100644 --- a/src/oxygen/errors.py +++ b/src/oxygen/errors.py @@ -28,3 +28,7 @@ class ResultFileIsNotAFileException(Exception): class MismatchArgumentException(Exception): pass + + +class InvalidConfigurationException(Exception): + pass diff --git a/src/oxygen/oxygen.py b/src/oxygen/oxygen.py index 825c146..fda5cf5 100644 --- a/src/oxygen/oxygen.py +++ b/src/oxygen/oxygen.py @@ -9,10 +9,12 @@ from robot.api import ExecutionResult, ResultVisitor, ResultWriter from robot.libraries.BuiltIn import BuiltIn from robot.errors import DataError -from yaml import load, FullLoader +from yaml import load, FullLoader, dump as dump_yaml from .config import CONFIG_FILE -from .errors import OxygenException +from .errors import (OxygenException, + InvalidConfigurationException, + ResultFileNotFoundException) from .robot_interface import RobotInterface from .version import VERSION @@ -32,9 +34,12 @@ def __init__(self): def _register_handlers(self): for tool_name, config in self._config.items(): - handler_class = getattr(__import__(tool_name, - fromlist=[config['handler']]), - config['handler']) + try: + handler_class = getattr(__import__(tool_name, + fromlist=[config['handler']]), + config['handler']) + except ModuleNotFoundError as e: + raise InvalidConfigurationException(e) handler = handler_class(config) self._handlers[tool_name] = handler @@ -211,31 +216,59 @@ class OxygenCLI(OxygenCore): OxygenCLI is a command line interface to transform one test result file to corresponding Robot Framework output.xml ''' - def parse_args(self, parser): + MAIN_LEVEL_CLI_ARGS = { + # we intentionally define `dest` here so we can filter arguments later + '--version': {'action': 'version', + 'dest': 'version'}, + '--add-config': {'type': Path, + 'metavar': 'FILE', + 'dest': 'add_config', + 'help': ('path to YAML file whose content is ' + 'appended to existing Oxygen handler ' + 'configuration')}, + '--reset-config': {'action': 'store_true', + 'dest': 'reset_config', + 'help': ('resets the Oxygen handler ' + 'configuration to a pristine, ' + 'as-freshly-installed version')}, + '--print-config': {'action': 'store_true', + 'dest': 'print_config', + 'help': ('prints current Oxygen handler ' + 'configuration')} + } + def add_arguments(self, parser): + # Add version number here to the arguments as it depends on OxygenCLI + # being initiated already + self.MAIN_LEVEL_CLI_ARGS['--version']['version'] = \ + f'%(prog)s {self.__version__}' + for flag, params in self.MAIN_LEVEL_CLI_ARGS.items(): + parser.add_argument(flag, **params) + subcommands = parser.add_subparsers() for tool_name, tool_handler in self._handlers.items(): subcommand_parser = subcommands.add_parser(tool_name) for flags, params in tool_handler.cli().items(): subcommand_parser.add_argument(*flags, **params) subcommand_parser.set_defaults(func=tool_handler.parse_results) + + def parse_args(self, parser): return vars(parser.parse_args()) # returns a dictionary def get_output_filename(self, result_file): + if result_file is None: + raise ResultFileNotFoundException('You did not give any result ' + 'file to convert') filename = Path(result_file) filename = filename.with_suffix('.xml') robot_name = filename.stem + '_robot_output' + filename.suffix filename = filename.with_name(robot_name) return str(filename) - def run(self): - parser = ArgumentParser(prog='oxygen') - parser.add_argument('--version', - action='version', - version=f'%(prog)s {self.__version__}') - args = self.parse_args(parser) - if not args: - parser.error('No arguments given') - output_filename = self.get_output_filename(args['result_file']) + def print_config(self): + print(dump_yaml(self._config)) + + def convert_to_robot_result(self, args): + output_filename = self.get_output_filename(args.get('result_file')) parsed_results = args['func']( **{k: v for (k, v) in args.items() if not callable(v)}) robot_suite = RobotInterface().running.build_suite(parsed_results) @@ -244,6 +277,30 @@ def run(self): report=None, stdout=StringIO()) + def run(self): + parser = ArgumentParser(prog='oxygen') + self.add_arguments(parser) + args = self.parse_args(parser) + match args: + case {'add_config': new_config_path} if new_config_path is not None: + pass #return self.append_config(new_config_path) + case {'reset_config': should_reset} if should_reset: + print(f'tiritrii {args}') #return self.reset_config() + case {'print_config': should_print} if should_print: + return self.print_config() + case {'add_config': _, + 'reset_config': _, + 'print_config': _, + **rest} if not rest: # user is not trying to invoke main-level arguments, but do not provide other arguments either + parser.error('No arguments given') + case _: + # filter out arguments meant for other cases so that downstream + # handler does not need to know about them + filter_list = [v['dest'] for v in + self.MAIN_LEVEL_CLI_ARGS.values()] + filtered_args = {k: v for k, v in args.items() + if k not in filter_list} + return self.convert_to_robot_result(filtered_args) if __name__ == '__main__': OxygenCLI().run() diff --git a/tests/utest/oxygen/test_oxygen_cli.py b/tests/utest/oxygen/test_oxygen_cli.py index 9833f4c..88e7cfd 100644 --- a/tests/utest/oxygen/test_oxygen_cli.py +++ b/tests/utest/oxygen/test_oxygen_cli.py @@ -96,12 +96,22 @@ def test_run(self, mock_parse_args, mock_robot_iface): ) def test_parse_args(self): + p = ArgumentParser() + + retval = self.cli.parse_args(p) + + self.assertIsInstance(retval, dict) + + def test_add_arguments(self): mock_parser = create_autospec(ArgumentParser) m = Mock() mock_parser.add_subparsers.return_value = m - self.cli.parse_args(mock_parser) + self.cli.add_arguments(mock_parser) + # verify all main-level cli arguments were added + self.assertEqual(len(mock_parser.add_argument.call_args_list), 4) + # verify all built-in handlers were added self.assertEqual(len(m.add_parser.call_args_list), 3) def test_get_output_filename(self): From c0a10c933f8357b6c42625b12d0825481cc7470f Mon Sep 17 00:00:00 2001 From: Tattoo Date: Sat, 14 Oct 2023 13:32:32 +0300 Subject: [PATCH 2/8] Fix tests and finish the sketching --- src/oxygen/__main__.py | 5 + src/oxygen/config.py | 2 +- src/oxygen/config_original.yml | 16 +++ src/oxygen/oxygen.py | 57 ++++++--- tests/utest/oxygen/test_oxygen_cli.py | 108 ++++++++++++++++-- tests/utest/oxygen/test_oxygen_config_file.py | 9 ++ tests/utest/oxygen/test_oxygen_core.py | 15 +++ tests/utest/oxygen/test_oxygenlibrary.py | 4 +- tests/utest/zap/test_zap_cli.py | 2 +- 9 files changed, 189 insertions(+), 29 deletions(-) create mode 100644 src/oxygen/config_original.yml create mode 100644 tests/utest/oxygen/test_oxygen_config_file.py create mode 100644 tests/utest/oxygen/test_oxygen_core.py diff --git a/src/oxygen/__main__.py b/src/oxygen/__main__.py index 5e62726..a66a345 100644 --- a/src/oxygen/__main__.py +++ b/src/oxygen/__main__.py @@ -1,4 +1,9 @@ +import sys + from .oxygen import OxygenCLI if __name__ == '__main__': + if '--reset-config' in sys.argv: + OxygenCLI.reset_config() + sys.exit(0) OxygenCLI().run() diff --git a/src/oxygen/config.py b/src/oxygen/config.py index 0f342af..b0fa146 100644 --- a/src/oxygen/config.py +++ b/src/oxygen/config.py @@ -1,4 +1,4 @@ from pathlib import Path CONFIG_FILE = Path(__file__).resolve().parent / 'config.yml' -ORIGINAL_CONFIG_FILE = Path(__file__).resolve().parent / 'original_config.yml' +ORIGINAL_CONFIG_FILE = Path(__file__).resolve().parent / 'config_original.yml' diff --git a/src/oxygen/config_original.yml b/src/oxygen/config_original.yml new file mode 100644 index 0000000..481ab62 --- /dev/null +++ b/src/oxygen/config_original.yml @@ -0,0 +1,16 @@ +oxygen.junit: + handler: JUnitHandler + keyword: run_junit + tags: + - oxygen-junit +oxygen.gatling: + handler: GatlingHandler + keyword: run_gatling + tags: oxygen-gatling +oxygen.zap: + handler: ZAProxyHandler + keyword: run_zap + tags: oxygen-zap + accepted_risk_level: 2 + required_confidence_level: 1 + diff --git a/src/oxygen/oxygen.py b/src/oxygen/oxygen.py index fda5cf5..4c66c21 100644 --- a/src/oxygen/oxygen.py +++ b/src/oxygen/oxygen.py @@ -1,9 +1,11 @@ +import sys from argparse import ArgumentParser from datetime import datetime, timedelta from inspect import getdoc, signature from io import StringIO from pathlib import Path +from shutil import copy as copy_file from traceback import format_exception from robot.api import ExecutionResult, ResultVisitor, ResultWriter @@ -11,7 +13,7 @@ from robot.errors import DataError from yaml import load, FullLoader, dump as dump_yaml -from .config import CONFIG_FILE +from .config import CONFIG_FILE, ORIGINAL_CONFIG_FILE from .errors import (OxygenException, InvalidConfigurationException, ResultFileNotFoundException) @@ -27,13 +29,28 @@ class OxygenCore(object): def __init__(self): - with open(CONFIG_FILE, 'r') as infile: + self._config = None + self._handlers = None + + @property + def config(self): + if self._config is None: + self.load_config(CONFIG_FILE) + return self._config + + @property + def handlers(self): + if self._handlers is None: + self._handlers = {} + self._register_handlers() + return self._handlers + + def load_config(self, config_file): + with open(config_file, 'r') as infile: self._config = load(infile, Loader=FullLoader) - self._handlers = {} - self._register_handlers() def _register_handlers(self): - for tool_name, config in self._config.items(): + for tool_name, config in self.config.items(): try: handler_class = getattr(__import__(tool_name, fromlist=[config['handler']]), @@ -58,7 +75,7 @@ def __init__(self, data): def visit_test(self, test): failures = [] - for handler_type, handler in self._handlers.items(): + for handler_type, handler in self.handlers.items(): try: handler.check_for_keyword(test, self.data) except Exception as e: @@ -188,12 +205,12 @@ def __init__(self): def _fetch_handler(self, name): try: return next(filter(lambda h: h.keyword == name, - self._handlers.values())) + self.handlers.values())) except StopIteration: raise OxygenException('No handler for keyword "{}"'.format(name)) def get_keyword_names(self): - return list(handler.keyword for handler in self._handlers.values()) + return list(handler.keyword for handler in self.handlers.values()) def run_keyword(self, name, args, kwargs): handler = self._fetch_handler(name) @@ -245,7 +262,7 @@ def add_arguments(self, parser): parser.add_argument(flag, **params) subcommands = parser.add_subparsers() - for tool_name, tool_handler in self._handlers.items(): + for tool_name, tool_handler in self.handlers.items(): subcommand_parser = subcommands.add_parser(tool_name) for flags, params in tool_handler.cli().items(): subcommand_parser.add_argument(*flags, **params) @@ -264,8 +281,21 @@ def get_output_filename(self, result_file): filename = filename.with_name(robot_name) return str(filename) + def append_config(self, new_config_path): + with open(new_config_path, 'r') as new_config: + with open(CONFIG_FILE, 'a') as old_config: + old_config.write(new_config.read()) + self.load_config(CONFIG_FILE) + + @staticmethod + def reset_config(): + copy_file(ORIGINAL_CONFIG_FILE, CONFIG_FILE) + OxygenCLI().load_config(CONFIG_FILE) + print('Oxygen handler configuration reset!') + def print_config(self): - print(dump_yaml(self._config)) + print(f'Using config file: {CONFIG_FILE}') + print(dump_yaml(self.config)) def convert_to_robot_result(self, args): output_filename = self.get_output_filename(args.get('result_file')) @@ -283,9 +313,7 @@ def run(self): args = self.parse_args(parser) match args: case {'add_config': new_config_path} if new_config_path is not None: - pass #return self.append_config(new_config_path) - case {'reset_config': should_reset} if should_reset: - print(f'tiritrii {args}') #return self.reset_config() + return self.append_config(new_config_path) case {'print_config': should_print} if should_print: return self.print_config() case {'add_config': _, @@ -303,4 +331,7 @@ def run(self): return self.convert_to_robot_result(filtered_args) if __name__ == '__main__': + if '--reset-config' in sys.argv: + OxygenCLI.reset_config() + sys.exit(0) OxygenCLI().run() diff --git a/tests/utest/oxygen/test_oxygen_cli.py b/tests/utest/oxygen/test_oxygen_cli.py index 88e7cfd..3adabf4 100644 --- a/tests/utest/oxygen/test_oxygen_cli.py +++ b/tests/utest/oxygen/test_oxygen_cli.py @@ -1,13 +1,15 @@ from argparse import ArgumentParser from pathlib import Path -from subprocess import check_output, run +from subprocess import check_output, run, STDOUT, CalledProcessError +from tempfile import mkstemp from unittest import TestCase from unittest.mock import ANY, create_autospec, patch, Mock from xml.etree import ElementTree from robot.running.model import TestSuite -from oxygen.oxygen import OxygenCLI +from oxygen.oxygen import OxygenCLI, OxygenCore +from oxygen.config import CONFIG_FILE, ORIGINAL_CONFIG_FILE from ..helpers import RESOURCES_PATH @@ -23,6 +25,11 @@ class TestOxygenCLIEntryPoints(TestCase): quite a hack: https://coverage.readthedocs.io/en/latest/subprocess.html ''' + def tearDown(self): + with open(ORIGINAL_CONFIG_FILE, 'r') as og: + with open(CONFIG_FILE, 'w') as config: + config.write(og.read()) + def test_main_level_entrypoint(self): self.verify_cli_help_text('python -m oxygen --help') self.verify_cli_help_text('python -m oxygen -h') @@ -39,8 +46,15 @@ def test_cli_with_no_args(self): self.assertEqual(proc.returncode, 2) self.assertIn('usage: oxygen', proc.stderr) + def _run(self, cmd): + try: + return check_output(cmd, text=True, shell=True, stderr=STDOUT) + except CalledProcessError as e: + print(e.output) # with this, you can actually see the command + raise # output, ie. why it failed + def verify_cli_help_text(self, cmd): - out = check_output(cmd, text=True, shell=True) + out = self._run(cmd) self.assertIn('usage: oxygen', out) self.assertIn('-h, --help', out) @@ -51,9 +65,7 @@ def test_junit_works_on_cli(self): if actual.exists(): actual.unlink() # delete file if exists - check_output(f'python -m oxygen oxygen.junit {target}', - text=True, - shell=True) + self._run(f'python -m oxygen oxygen.junit {target}') example_xml = ElementTree.parse(example).getroot() actual_xml = ElementTree.parse(actual).getroot() @@ -68,6 +80,66 @@ def test_junit_works_on_cli(self): self.assertEqual(example_stat.get('pass'), actual_stat.get('pass')) self.assertEqual(example_stat.get('fail'), actual_stat.get('fail')) + def _validate_handler_names(self, text): + for handler in ('JUnitHandler', 'GatlingHandler', 'ZAProxyHandler'): + self.assertIn(handler, text) + + def test_reset_config(self): + with open(CONFIG_FILE, 'w') as f: + f.write('complete: gibberish') + + self._run(f'python -m oxygen --reset-config') + + with open(CONFIG_FILE, 'r') as f: + config_content = f.read() + self.assertNotIn('complete: gibberish', config_content) + self._validate_handler_names(config_content) + + def test_print_config(self): + out = self._run('python -m oxygen --print-config') + + self.assertIn('Using config file', out) + self._validate_handler_names(out) + + def _make_test_config(self): + _, filepath = mkstemp() + with open(filepath, 'w') as f: + f.write('complete: gibberish') + return filepath + + def test_add_config(self): + filepath = self._make_test_config() + + self._run(f'python -m oxygen --add-config {filepath}') + + + with open(CONFIG_FILE, 'r') as f: + config_content = f.read() + self._validate_handler_names(config_content) + self.assertIn('complete: gibberish', config_content) + + def _is_file_content(self, filepath, text): + with open(filepath, 'r') as f: + return bool(text in f.read()) + + def test_main_level_args_override_handler_args(self): + filepath = self._make_test_config() + + cmd = ('python -m oxygen {main_level_arg} ' + f'oxygen.junit {RESOURCES_PATH / "green-junit-example.xml"}') + + self._run(cmd.format(main_level_arg=f'--add-config {filepath}')) + self.assertTrue(self._is_file_content(CONFIG_FILE, 'complete: gibberish')) + + self._run(cmd.format(main_level_arg='--reset-config')) + self.assertFalse(self._is_file_content(CONFIG_FILE, + 'complete: gibberish')) + + + out = self._run(cmd.format(main_level_arg='--print-config')) + self._validate_handler_names(out) + self.assertNotIn('gibberish', out) + class TestOxygenCLI(TestCase): @@ -96,6 +168,7 @@ def test_run(self, mock_parse_args, mock_robot_iface): ) def test_parse_args(self): + '''verifies that `parse_args()` returns a dictionary''' p = ArgumentParser() retval = self.cli.parse_args(p) @@ -114,11 +187,22 @@ def test_add_arguments(self): # verify all built-in handlers were added self.assertEqual(len(m.add_parser.call_args_list), 3) + def _actual(self, path): + return self.cli.get_output_filename(path) + + def _expected(self, path): + return str(Path(path)) + def test_get_output_filename(self): - self.assertEqual(self.cli.get_output_filename('absolute/path/to.file'), - str(Path('absolute/path/to_robot_output.xml'))) - self.assertEqual(self.cli.get_output_filename('path/to/file.xml'), - str(Path('path/to/file_robot_output.xml'))) - self.assertEqual(self.cli.get_output_filename('file.extension'), - str(Path('file_robot_output.xml'))) + for act, exp in ((self._actual('/absolute/path/to.file'), + self._expected('/absolute/path/to_robot_output.xml')), + + (self._actual('path/to/file.xml'), + self._expected('path/to/file_robot_output.xml')), + + (self._actual('file.extension'), + self._expected('file_robot_output.xml'))): + self.assertEqual(act, exp) + + diff --git a/tests/utest/oxygen/test_oxygen_config_file.py b/tests/utest/oxygen/test_oxygen_config_file.py new file mode 100644 index 0000000..8a2bc24 --- /dev/null +++ b/tests/utest/oxygen/test_oxygen_config_file.py @@ -0,0 +1,9 @@ +from unittest import TestCase + +from oxygen.config import CONFIG_FILE, ORIGINAL_CONFIG_FILE + +class TestOxygenCLIEntryPoints(TestCase): + def test_config_and_config_original_match(self): + with open(CONFIG_FILE, 'r') as config: + with open(ORIGINAL_CONFIG_FILE, 'r') as original_config: + self.assertEqual(config.read(), original_config.read()) diff --git a/tests/utest/oxygen/test_oxygen_core.py b/tests/utest/oxygen/test_oxygen_core.py new file mode 100644 index 0000000..eae0352 --- /dev/null +++ b/tests/utest/oxygen/test_oxygen_core.py @@ -0,0 +1,15 @@ +from unittest import TestCase +from oxygen.oxygen import OxygenCore + + +class TestOxygenInitialization(TestCase): + def test_oxygen_core_initializes_without_loading_config(self): + ''' + OxygenCore and all it's subclasses lazy-load the configuration and, + consequently, the handlers. This test makes sure that is not + accidentally changed at some point + ''' + core = OxygenCore() + self.assertEqual(core._config, None) + self.assertEqual(core._handlers, None) + diff --git a/tests/utest/oxygen/test_oxygenlibrary.py b/tests/utest/oxygen/test_oxygenlibrary.py index 21b2774..61f4121 100644 --- a/tests/utest/oxygen/test_oxygenlibrary.py +++ b/tests/utest/oxygen/test_oxygenlibrary.py @@ -21,8 +21,8 @@ def test_initialization(self): def test_config_is_correct(self, mock_config): mock_config.return_value = get_config_as_file() - self.assertGreater(len(self.lib._handlers), 1) - for handler in self.lib._handlers.values(): + self.assertGreater(len(self.lib.handlers), 1) + for handler in self.lib.handlers.values(): self.assertTrue(isinstance(handler, BaseHandler)) self.assertTrue(any(hasattr(handler, kw) for kw in self.EXPECTED_KEYWORDS), diff --git a/tests/utest/zap/test_zap_cli.py b/tests/utest/zap/test_zap_cli.py index 76023c0..49db088 100644 --- a/tests/utest/zap/test_zap_cli.py +++ b/tests/utest/zap/test_zap_cli.py @@ -11,7 +11,7 @@ class TestOxygenZapCLI(TestCase): def setUp(self): self.cli = OxygenCLI() - self.handler = self.cli._handlers["oxygen.zap"] + self.handler = self.cli.handlers["oxygen.zap"] self.expected_suite = create_autospec(TestSuite) self.mock = Mock() self.mock.running.build_suite = Mock(return_value=self.expected_suite) From 5eef84f3be7a21c5fa71e50f448c91447a929671 Mon Sep 17 00:00:00 2001 From: Tattoo Date: Sun, 15 Oct 2023 13:34:01 +0300 Subject: [PATCH 3/8] OxygenCore: reorganize code for readability --- src/oxygen/oxygen.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/oxygen/oxygen.py b/src/oxygen/oxygen.py index 4c66c21..1887648 100644 --- a/src/oxygen/oxygen.py +++ b/src/oxygen/oxygen.py @@ -38,6 +38,10 @@ def config(self): self.load_config(CONFIG_FILE) return self._config + def load_config(self, config_file): + with open(config_file, 'r') as infile: + self._config = load(infile, Loader=FullLoader) + @property def handlers(self): if self._handlers is None: @@ -45,10 +49,6 @@ def handlers(self): self._register_handlers() return self._handlers - def load_config(self, config_file): - with open(config_file, 'r') as infile: - self._config = load(infile, Loader=FullLoader) - def _register_handlers(self): for tool_name, config in self.config.items(): try: From e6712c7be6b95e2ea9f0c676be47e109244f5ee7 Mon Sep 17 00:00:00 2001 From: Tattoo Date: Sun, 15 Oct 2023 13:42:15 +0300 Subject: [PATCH 4/8] Refactor main CLI entrypoint to remove duplication --- src/oxygen/__main__.py | 7 ++----- src/oxygen/oxygen.py | 9 ++++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/oxygen/__main__.py b/src/oxygen/__main__.py index a66a345..01ae6f0 100644 --- a/src/oxygen/__main__.py +++ b/src/oxygen/__main__.py @@ -1,9 +1,6 @@ import sys -from .oxygen import OxygenCLI +from .oxygen import OxygenCLI, main if __name__ == '__main__': - if '--reset-config' in sys.argv: - OxygenCLI.reset_config() - sys.exit(0) - OxygenCLI().run() + main() diff --git a/src/oxygen/oxygen.py b/src/oxygen/oxygen.py index 1887648..d7910a0 100644 --- a/src/oxygen/oxygen.py +++ b/src/oxygen/oxygen.py @@ -330,8 +330,15 @@ def run(self): if k not in filter_list} return self.convert_to_robot_result(filtered_args) -if __name__ == '__main__': +def main(): + '''Main CLI entrypoint + + Also used in __main__.py + ''' if '--reset-config' in sys.argv: OxygenCLI.reset_config() sys.exit(0) OxygenCLI().run() + +if __name__ == '__main__': + main() From 6375b3d417ce493098a618114a04817459cd755c Mon Sep 17 00:00:00 2001 From: Tattoo Date: Mon, 16 Oct 2023 00:21:17 +0300 Subject: [PATCH 5/8] Add new handler config options to README --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b38bb9..1d334c3 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,75 @@ $ python -m oxygen oxygen.gatling path/to/results.log Then `results_robot_output.xml` will be created under `path/to/`. +## Extending Oxygen: writing your own handler + +### [Read the developer guide on how to write your own handler](DEVGUIDE.md) + +### Configuring your handler to Oxygen + +Oxygen knows about different handlers based on the [`config.yml`](https://github.com/eficode/robotframework-oxygen/blob/master/config.yml) file. This configuration file can be interacted with through Oxygen's command line. + +The configuration has the following parts: +```yml +oxygen.junit: # Python module. Oxygen will use this key to try to import the handler + handler: JUnitHandler # Class that Oxygen will initiate after the handler is imported + keyword: run_junit # Keyword that should be used to run the other test tool + tags: # List of tags that by default should be added to the test cases converted with this handler + - oxygen-junit +oxygen.zap: + handler: ZAProxyHandler + keyword: run_zap + tags: oxygen-zap + accepted_risk_level: 2 # Handlers can have their own command line arguments + required_confidence_level: 1 # See [the development guide](DEVGUIDE.md) for more information +``` + +#### `--add-config` + +This argument is used to add new handler configuration to Oxygen: + +```bash +$ python -m oxygen --add-config path/to/your_handler_config.yml +``` + +This file is read and appended to the Oxygen's `config.yml`. Based on the key, Oxygen will try to import you handler. + +### `--reset-config` + +This argument is used to return Oxygen's `config.yml` back to the state it was when the tool was installed: + +```bash +$ python -m oxygen --reset-config +``` + +The command **does not** verify the operation from the user, so be careful. + +### `--print-config` + +This argument prints the current configuration of Oxygen: +```bash +$ python -m oxygen --print-config +Using config file: /path/to/oxygen/src/oxygen/config.yml +oxygen.gatling: + handler: GatlingHandler + keyword: run_gatling + tags: oxygen-gatling +oxygen.junit: + handler: JUnitHandler + keyword: run_junit + tags: + - oxygen-junit +oxygen.zap: + accepted_risk_level: 2 + handler: ZAProxyHandler + keyword: run_zap + required_confidence_level: 1 + tags: oxygen-zap + +$ +``` +Because you can add the configuration to the same handler multiple times, note that only the last entry is in effect. + # Developing Oxygen Clone the Oxygen repository to the environment where you want to the run the tool. @@ -107,7 +176,6 @@ $ invoke --list and the task file [`tasks.py`](https://github.com/eficode/robotframework-oxygen/blob/master/tasks.py). -[Read the developer guide on how to write your own handler](DEVGUIDE.md) # License From 6c4a267bc6ddc480779f8c3eef1c647c55fd7f2d Mon Sep 17 00:00:00 2001 From: Tattoo Date: Wed, 18 Oct 2023 18:06:47 +0300 Subject: [PATCH 6/8] remove unused import --- src/oxygen/__main__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/oxygen/__main__.py b/src/oxygen/__main__.py index 01ae6f0..89cdd34 100644 --- a/src/oxygen/__main__.py +++ b/src/oxygen/__main__.py @@ -1,5 +1,3 @@ -import sys - from .oxygen import OxygenCLI, main if __name__ == '__main__': From 5bf9f291fbb48e0e4a48a7fa9c9bd7d452feac51 Mon Sep 17 00:00:00 2001 From: Tattoo Date: Thu, 19 Oct 2023 11:58:13 +0300 Subject: [PATCH 7/8] fix tests that fail in the pipeline --- .github/workflows/run-tests/action.yml | 1 - requirements.txt | 3 ++- src/oxygen/oxygen.py | 10 +++++----- tasks.py | 7 +++---- tests/atest/oxygen_junit_tests.robot | 4 ++-- tests/utest/oxygen/test_oxygen_cli.py | 12 ++++++++---- tests/utest/zap/test_zap_cli.py | 11 ++++++++++- 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/.github/workflows/run-tests/action.yml b/.github/workflows/run-tests/action.yml index 4c047d5..15606f9 100644 --- a/.github/workflows/run-tests/action.yml +++ b/.github/workflows/run-tests/action.yml @@ -13,7 +13,6 @@ runs: uses: actions/setup-python@v4 with: python-version: ${{ inputs.python-version }} - cache: 'pip' - name: Install dependencies shell: ${{ inputs.terminal }} run: | diff --git a/requirements.txt b/requirements.txt index 7f9413f..da6a5c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,8 @@ mock>=2.0.0 invoke>=1.1.1 coverage>=5.1 testfixtures>=6.14.1 # needed for large dict comparisons to make sense of them -green>=3.1.3 # unit test runner +pytest>=7.4.2 +pytest-cov>=4.1.0 docutils>=0.16 # needed to generate library documentation with libdoc Pygments>=2.6.1 # this one too twine>=3.1.1 # needed for releasing to pypi diff --git a/src/oxygen/oxygen.py b/src/oxygen/oxygen.py index 4f0af4d..524cb84 100644 --- a/src/oxygen/oxygen.py +++ b/src/oxygen/oxygen.py @@ -50,14 +50,14 @@ def handlers(self): return self._handlers def _register_handlers(self): - for tool_name, config in self.config.items(): + for tool_name, handler_config in self.config.items(): try: - handler_class = getattr(__import__(tool_name, - fromlist=[config['handler']]), - config['handler']) + handler_class = getattr( + __import__(tool_name, fromlist=[handler_config['handler']]), + handler_config['handler']) except ModuleNotFoundError as e: raise InvalidConfigurationException(e) - handler = handler_class(config) + handler = handler_class(handler_config) self._handlers[tool_name] = handler diff --git a/tasks.py b/tasks.py index 9d47463..d2d54f6 100644 --- a/tasks.py +++ b/tasks.py @@ -34,17 +34,16 @@ def install(context, package=None): @task(iterable=['test'], help={ 'test': 'Limit unit test execution to specific tests. Must be given ' - 'multiple times to select several targets. See more: ' - 'https://github.com/CleanCut/green/blob/master/cli-options.txt#L5', + 'multiple times to select several targets.' }) def utest(context, test=None): - run(f'green {" ".join(test) if test else UNIT_TESTS}', + run(f'pytest {" ".join(test) if test else UNIT_TESTS} -q --disable-warnings', env={'PYTHONPATH': str(SRCPATH)}, pty=(not system() == 'Windows')) @task def coverage(context): - run(f'green -r {str(UNIT_TESTS)}', + run(f'pytest --cov {UNIT_TESTS}', env={'PYTHONPATH': str(SRCPATH)}, pty=(not system() == 'Windows')) run('coverage html') diff --git a/tests/atest/oxygen_junit_tests.robot b/tests/atest/oxygen_junit_tests.robot index 2cefb43..aacbf9f 100644 --- a/tests/atest/oxygen_junit_tests.robot +++ b/tests/atest/oxygen_junit_tests.robot @@ -16,9 +16,9 @@ Oxygen's unit tests should pass [Tags] oxygen-own-junit Remove file ${JUNIT XML FILE} File should not exist ${JUNIT XML FILE} - ${green}= Get command green + ${pytest}= Get command pytest Run JUnit ${JUNIT XML FILE} - ... ${green} -j ${JUNIT XML FILE} ${EXECDIR} + ... ${pytest} --junit-xml\=${JUNIT XML FILE} ${EXECDIR} File should exist ${JUNIT XML FILE} *** Keywords *** diff --git a/tests/utest/oxygen/test_oxygen_cli.py b/tests/utest/oxygen/test_oxygen_cli.py index 3adabf4..465086c 100644 --- a/tests/utest/oxygen/test_oxygen_cli.py +++ b/tests/utest/oxygen/test_oxygen_cli.py @@ -1,4 +1,4 @@ -from argparse import ArgumentParser +from argparse import ArgumentParser, Namespace from pathlib import Path from subprocess import check_output, run, STDOUT, CalledProcessError from tempfile import mkstemp @@ -25,11 +25,15 @@ class TestOxygenCLIEntryPoints(TestCase): quite a hack: https://coverage.readthedocs.io/en/latest/subprocess.html ''' - def tearDown(self): + @classmethod + def tearDownClass(cls): with open(ORIGINAL_CONFIG_FILE, 'r') as og: with open(CONFIG_FILE, 'w') as config: config.write(og.read()) + def tearDown(self): + self.tearDownClass() + def test_main_level_entrypoint(self): self.verify_cli_help_text('python -m oxygen --help') self.verify_cli_help_text('python -m oxygen -h') @@ -112,7 +116,6 @@ def test_add_config(self): self._run(f'python -m oxygen --add-config {filepath}') - with open(CONFIG_FILE, 'r') as f: config_content = f.read() self._validate_handler_names(config_content) @@ -169,7 +172,8 @@ def test_run(self, mock_parse_args, mock_robot_iface): def test_parse_args(self): '''verifies that `parse_args()` returns a dictionary''' - p = ArgumentParser() + p = create_autospec(ArgumentParser) + p.parse_args.return_value = create_autospec(Namespace) retval = self.cli.parse_args(p) diff --git a/tests/utest/zap/test_zap_cli.py b/tests/utest/zap/test_zap_cli.py index 49db088..710dbf9 100644 --- a/tests/utest/zap/test_zap_cli.py +++ b/tests/utest/zap/test_zap_cli.py @@ -1,7 +1,10 @@ import sys + from unittest import TestCase from unittest.mock import ANY, Mock, create_autospec, patch + from robot.running.model import TestSuite + from oxygen.oxygen import OxygenCLI from ..helpers import RESOURCES_PATH @@ -16,6 +19,12 @@ def setUp(self): self.mock = Mock() self.mock.running.build_suite = Mock(return_value=self.expected_suite) + def tearDown(self): + self.cli = None + self.handler = None + self.expected_suite = None + self.mock = None + def test_cli(self): self.assertEqual( self.handler.cli(), @@ -81,7 +90,7 @@ def test_cli_run_with_accepted_risk_level(self, mock_robot_iface): def test_cli_run_with_required_confidence_level(self, mock_robot_iface): mock_robot_iface.return_value = self.mock - cmd_args = f"oxygen oxygen.zap {self.ZAP_XML} " "--required-confidence-level 3" + cmd_args = f"oxygen oxygen.zap {self.ZAP_XML} --required-confidence-level 3" with patch.object(sys, "argv", cmd_args.split()): self.cli.run() From ab0175f6ea3b0062ff20a3585eac2ac335a26f76 Mon Sep 17 00:00:00 2001 From: Tattoo Date: Fri, 20 Oct 2023 11:49:47 +0300 Subject: [PATCH 8/8] Fix test that fails on specific macos --- .../robot_interface/test_time_conversions.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/utest/robot_interface/test_time_conversions.py b/tests/utest/robot_interface/test_time_conversions.py index 4a4498b..bda2e68 100644 --- a/tests/utest/robot_interface/test_time_conversions.py +++ b/tests/utest/robot_interface/test_time_conversions.py @@ -1,5 +1,8 @@ +from datetime import timedelta from unittest import TestCase +from mock import patch + from oxygen.robot_interface import RobotInterface @@ -29,22 +32,22 @@ def test_should_be_associative(self): timestamp = self.interface.result.ms_to_timestamp(milliseconds) self.assertEqual(timestamp, '20180807 07:01:24.300000') - def _validate_timestamp(self, result): - timestamp = result.ms_to_timestamp(-10) + def _validate_timestamp(self, interface): + timestamp = interface.ms_to_timestamp(-10) expected = '19700101 00:00:00.990000' - import platform - # Particular Windows 10 calculates epoch differently ( T ʖ̯ T) - if platform.system() == 'Windows' and platform.version() == '10.0.19044': - expected = '19700101 02:00:00.990000' self.assertEqual(timestamp, expected) def test_ms_before_epoch_are_reset_to_epoch(self): from oxygen.robot4_interface import RobotResultInterface as RF4ResultIface - self._validate_timestamp(RF4ResultIface()) + with patch.object(RF4ResultIface, 'get_timezone_delta') as m: + m.return_value = timedelta(seconds=7200) + self._validate_timestamp(RF4ResultIface()) from oxygen.robot3_interface import RobotResultInterface as RF3ResultIface - self._validate_timestamp(RF3ResultIface()) + with patch.object(RF3ResultIface, 'get_timezone_delta') as m: + m.return_value = timedelta(seconds=7200) + self._validate_timestamp(RF3ResultIface()) class TestTimestampToMs(TestCase):