Skip to content

Commit

Permalink
Merge pull request #42 from eficode/enhance_handler_config
Browse files Browse the repository at this point in the history
Enhance handler config. Fixes #4
  • Loading branch information
Tattoo authored Oct 20, 2023
2 parents 091ab6d + ab0175f commit 1abdc0d
Show file tree
Hide file tree
Showing 17 changed files with 383 additions and 65 deletions.
1 change: 0 additions & 1 deletion .github/workflows/run-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
70 changes: 69 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/oxygen/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .oxygen import OxygenCLI
from .oxygen import OxygenCLI, main

if __name__ == '__main__':
OxygenCLI().run()
main()
5 changes: 3 additions & 2 deletions src/oxygen/config.py
Original file line number Diff line number Diff line change
@@ -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 / 'config_original.yml'
1 change: 1 addition & 0 deletions src/oxygen/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ oxygen.zap:
tags: oxygen-zap
accepted_risk_level: 2
required_confidence_level: 1

16 changes: 16 additions & 0 deletions src/oxygen/config_original.yml
Original file line number Diff line number Diff line change
@@ -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

4 changes: 4 additions & 0 deletions src/oxygen/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ class ResultFileIsNotAFileException(Exception):

class MismatchArgumentException(Exception):
pass


class InvalidConfigurationException(Exception):
pass
147 changes: 121 additions & 26 deletions src/oxygen/oxygen.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
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
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 .config import CONFIG_FILE, ORIGINAL_CONFIG_FILE
from .errors import (OxygenException,
InvalidConfigurationException,
ResultFileNotFoundException)
from .robot_interface import RobotInterface
from .version import VERSION

Expand All @@ -25,17 +29,35 @@ 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

def load_config(self, config_file):
with open(config_file, 'r') as infile:
self._config = load(infile, Loader=FullLoader)
self._handlers = {}
self._register_handlers()

@property
def handlers(self):
if self._handlers is None:
self._handlers = {}
self._register_handlers()
return self._handlers

def _register_handlers(self):
for tool_name, config in self._config.items():
handler_class = getattr(__import__(tool_name,
fromlist=[config['handler']]),
config['handler'])
handler = handler_class(config)
for tool_name, handler_config in self.config.items():
try:
handler_class = getattr(
__import__(tool_name, fromlist=[handler_config['handler']]),
handler_config['handler'])
except ModuleNotFoundError as e:
raise InvalidConfigurationException(e)
handler = handler_class(handler_config)
self._handlers[tool_name] = handler


Expand All @@ -53,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:
Expand Down Expand Up @@ -182,12 +204,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)
Expand All @@ -210,31 +232,72 @@ 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():
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 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(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'))
parsed_results = args['func'](
**{k: v for (k, v) in args.items() if not callable(v)})
robot_suite = RobotInterface().running.build_suite(parsed_results)
Expand All @@ -243,6 +306,38 @@ 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:
return self.append_config(new_config_path)
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)

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__':
OxygenCLI().run()
main()
7 changes: 3 additions & 4 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions tests/atest/oxygen_junit_tests.robot
Original file line number Diff line number Diff line change
Expand Up @@ -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 ***
Expand Down
Loading

0 comments on commit 1abdc0d

Please sign in to comment.