Skip to content

Commit 1abdc0d

Browse files
authored
Merge pull request #42 from eficode/enhance_handler_config
Enhance handler config. Fixes #4
2 parents 091ab6d + ab0175f commit 1abdc0d

17 files changed

+383
-65
lines changed

.github/workflows/run-tests/action.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ runs:
1313
uses: actions/setup-python@v4
1414
with:
1515
python-version: ${{ inputs.python-version }}
16-
cache: 'pip'
1716
- name: Install dependencies
1817
shell: ${{ inputs.terminal }}
1918
run: |

README.md

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,75 @@ $ python -m oxygen oxygen.gatling path/to/results.log
8989

9090
Then `results_robot_output.xml` will be created under `path/to/`.
9191

92+
## Extending Oxygen: writing your own handler
93+
94+
### [Read the developer guide on how to write your own handler](DEVGUIDE.md)
95+
96+
### Configuring your handler to Oxygen
97+
98+
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.
99+
100+
The configuration has the following parts:
101+
```yml
102+
oxygen.junit: # Python module. Oxygen will use this key to try to import the handler
103+
handler: JUnitHandler # Class that Oxygen will initiate after the handler is imported
104+
keyword: run_junit # Keyword that should be used to run the other test tool
105+
tags: # List of tags that by default should be added to the test cases converted with this handler
106+
- oxygen-junit
107+
oxygen.zap:
108+
handler: ZAProxyHandler
109+
keyword: run_zap
110+
tags: oxygen-zap
111+
accepted_risk_level: 2 # Handlers can have their own command line arguments
112+
required_confidence_level: 1 # See [the development guide](DEVGUIDE.md) for more information
113+
```
114+
115+
#### `--add-config`
116+
117+
This argument is used to add new handler configuration to Oxygen:
118+
119+
```bash
120+
$ python -m oxygen --add-config path/to/your_handler_config.yml
121+
```
122+
123+
This file is read and appended to the Oxygen's `config.yml`. Based on the key, Oxygen will try to import you handler.
124+
125+
### `--reset-config`
126+
127+
This argument is used to return Oxygen's `config.yml` back to the state it was when the tool was installed:
128+
129+
```bash
130+
$ python -m oxygen --reset-config
131+
```
132+
133+
The command **does not** verify the operation from the user, so be careful.
134+
135+
### `--print-config`
136+
137+
This argument prints the current configuration of Oxygen:
138+
```bash
139+
$ python -m oxygen --print-config
140+
Using config file: /path/to/oxygen/src/oxygen/config.yml
141+
oxygen.gatling:
142+
handler: GatlingHandler
143+
keyword: run_gatling
144+
tags: oxygen-gatling
145+
oxygen.junit:
146+
handler: JUnitHandler
147+
keyword: run_junit
148+
tags:
149+
- oxygen-junit
150+
oxygen.zap:
151+
accepted_risk_level: 2
152+
handler: ZAProxyHandler
153+
keyword: run_zap
154+
required_confidence_level: 1
155+
tags: oxygen-zap
156+
157+
$
158+
```
159+
Because you can add the configuration to the same handler multiple times, note that only the last entry is in effect.
160+
92161
# Developing Oxygen
93162

94163
Clone the Oxygen repository to the environment where you want to the run the tool.
@@ -107,7 +176,6 @@ $ invoke --list
107176

108177
and the task file [`tasks.py`](https://github.com/eficode/robotframework-oxygen/blob/master/tasks.py).
109178

110-
[Read the developer guide on how to write your own handler](DEVGUIDE.md)
111179

112180
# License
113181

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ mock>=2.0.0
77
invoke>=1.1.1
88
coverage>=5.1
99
testfixtures>=6.14.1 # needed for large dict comparisons to make sense of them
10-
green>=3.1.3 # unit test runner
10+
pytest>=7.4.2
11+
pytest-cov>=4.1.0
1112
docutils>=0.16 # needed to generate library documentation with libdoc
1213
Pygments>=2.6.1 # this one too
1314
twine>=3.1.1 # needed for releasing to pypi

src/oxygen/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .oxygen import OxygenCLI
1+
from .oxygen import OxygenCLI, main
22

33
if __name__ == '__main__':
4-
OxygenCLI().run()
4+
main()

src/oxygen/config.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
from os.path import abspath, dirname, join
1+
from pathlib import Path
22

3-
CONFIG_FILE = join(abspath(dirname(__file__)), 'config.yml')
3+
CONFIG_FILE = Path(__file__).resolve().parent / 'config.yml'
4+
ORIGINAL_CONFIG_FILE = Path(__file__).resolve().parent / 'config_original.yml'

src/oxygen/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ oxygen.zap:
1313
tags: oxygen-zap
1414
accepted_risk_level: 2
1515
required_confidence_level: 1
16+

src/oxygen/config_original.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
oxygen.junit:
2+
handler: JUnitHandler
3+
keyword: run_junit
4+
tags:
5+
- oxygen-junit
6+
oxygen.gatling:
7+
handler: GatlingHandler
8+
keyword: run_gatling
9+
tags: oxygen-gatling
10+
oxygen.zap:
11+
handler: ZAProxyHandler
12+
keyword: run_zap
13+
tags: oxygen-zap
14+
accepted_risk_level: 2
15+
required_confidence_level: 1
16+

src/oxygen/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ class ResultFileIsNotAFileException(Exception):
2828

2929
class MismatchArgumentException(Exception):
3030
pass
31+
32+
33+
class InvalidConfigurationException(Exception):
34+
pass

src/oxygen/oxygen.py

Lines changed: 121 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1+
import sys
12

23
from argparse import ArgumentParser
34
from datetime import datetime, timedelta
45
from inspect import getdoc, signature
56
from io import StringIO
67
from pathlib import Path
8+
from shutil import copy as copy_file
79
from traceback import format_exception
810

911
from robot.api import ExecutionResult, ResultVisitor, ResultWriter
1012
from robot.libraries.BuiltIn import BuiltIn
1113
from robot.errors import DataError
12-
from yaml import load, FullLoader
14+
from yaml import load, FullLoader, dump as dump_yaml
1315

14-
from .config import CONFIG_FILE
15-
from .errors import OxygenException
16+
from .config import CONFIG_FILE, ORIGINAL_CONFIG_FILE
17+
from .errors import (OxygenException,
18+
InvalidConfigurationException,
19+
ResultFileNotFoundException)
1620
from .robot_interface import RobotInterface
1721
from .version import VERSION
1822

@@ -25,17 +29,35 @@ class OxygenCore(object):
2529

2630

2731
def __init__(self):
28-
with open(CONFIG_FILE, 'r') as infile:
32+
self._config = None
33+
self._handlers = None
34+
35+
@property
36+
def config(self):
37+
if self._config is None:
38+
self.load_config(CONFIG_FILE)
39+
return self._config
40+
41+
def load_config(self, config_file):
42+
with open(config_file, 'r') as infile:
2943
self._config = load(infile, Loader=FullLoader)
30-
self._handlers = {}
31-
self._register_handlers()
44+
45+
@property
46+
def handlers(self):
47+
if self._handlers is None:
48+
self._handlers = {}
49+
self._register_handlers()
50+
return self._handlers
3251

3352
def _register_handlers(self):
34-
for tool_name, config in self._config.items():
35-
handler_class = getattr(__import__(tool_name,
36-
fromlist=[config['handler']]),
37-
config['handler'])
38-
handler = handler_class(config)
53+
for tool_name, handler_config in self.config.items():
54+
try:
55+
handler_class = getattr(
56+
__import__(tool_name, fromlist=[handler_config['handler']]),
57+
handler_config['handler'])
58+
except ModuleNotFoundError as e:
59+
raise InvalidConfigurationException(e)
60+
handler = handler_class(handler_config)
3961
self._handlers[tool_name] = handler
4062

4163

@@ -53,7 +75,7 @@ def __init__(self, data):
5375

5476
def visit_test(self, test):
5577
failures = []
56-
for handler_type, handler in self._handlers.items():
78+
for handler_type, handler in self.handlers.items():
5779
try:
5880
handler.check_for_keyword(test, self.data)
5981
except Exception as e:
@@ -182,12 +204,12 @@ def __init__(self):
182204
def _fetch_handler(self, name):
183205
try:
184206
return next(filter(lambda h: h.keyword == name,
185-
self._handlers.values()))
207+
self.handlers.values()))
186208
except StopIteration:
187209
raise OxygenException('No handler for keyword "{}"'.format(name))
188210

189211
def get_keyword_names(self):
190-
return list(handler.keyword for handler in self._handlers.values())
212+
return list(handler.keyword for handler in self.handlers.values())
191213

192214
def run_keyword(self, name, args, kwargs):
193215
handler = self._fetch_handler(name)
@@ -210,31 +232,72 @@ class OxygenCLI(OxygenCore):
210232
OxygenCLI is a command line interface to transform one test result file to
211233
corresponding Robot Framework output.xml
212234
'''
213-
def parse_args(self, parser):
235+
MAIN_LEVEL_CLI_ARGS = {
236+
# we intentionally define `dest` here so we can filter arguments later
237+
'--version': {'action': 'version',
238+
'dest': 'version'},
239+
'--add-config': {'type': Path,
240+
'metavar': 'FILE',
241+
'dest': 'add_config',
242+
'help': ('path to YAML file whose content is '
243+
'appended to existing Oxygen handler '
244+
'configuration')},
245+
'--reset-config': {'action': 'store_true',
246+
'dest': 'reset_config',
247+
'help': ('resets the Oxygen handler '
248+
'configuration to a pristine, '
249+
'as-freshly-installed version')},
250+
'--print-config': {'action': 'store_true',
251+
'dest': 'print_config',
252+
'help': ('prints current Oxygen handler '
253+
'configuration')}
254+
}
255+
def add_arguments(self, parser):
256+
# Add version number here to the arguments as it depends on OxygenCLI
257+
# being initiated already
258+
self.MAIN_LEVEL_CLI_ARGS['--version']['version'] = \
259+
f'%(prog)s {self.__version__}'
260+
for flag, params in self.MAIN_LEVEL_CLI_ARGS.items():
261+
parser.add_argument(flag, **params)
262+
214263
subcommands = parser.add_subparsers()
215-
for tool_name, tool_handler in self._handlers.items():
264+
for tool_name, tool_handler in self.handlers.items():
216265
subcommand_parser = subcommands.add_parser(tool_name)
217266
for flags, params in tool_handler.cli().items():
218267
subcommand_parser.add_argument(*flags, **params)
219268
subcommand_parser.set_defaults(func=tool_handler.parse_results)
269+
270+
def parse_args(self, parser):
220271
return vars(parser.parse_args()) # returns a dictionary
221272

222273
def get_output_filename(self, result_file):
274+
if result_file is None:
275+
raise ResultFileNotFoundException('You did not give any result '
276+
'file to convert')
223277
filename = Path(result_file)
224278
filename = filename.with_suffix('.xml')
225279
robot_name = filename.stem + '_robot_output' + filename.suffix
226280
filename = filename.with_name(robot_name)
227281
return str(filename)
228282

229-
def run(self):
230-
parser = ArgumentParser(prog='oxygen')
231-
parser.add_argument('--version',
232-
action='version',
233-
version=f'%(prog)s {self.__version__}')
234-
args = self.parse_args(parser)
235-
if not args:
236-
parser.error('No arguments given')
237-
output_filename = self.get_output_filename(args['result_file'])
283+
def append_config(self, new_config_path):
284+
with open(new_config_path, 'r') as new_config:
285+
with open(CONFIG_FILE, 'a') as old_config:
286+
old_config.write(new_config.read())
287+
self.load_config(CONFIG_FILE)
288+
289+
@staticmethod
290+
def reset_config():
291+
copy_file(ORIGINAL_CONFIG_FILE, CONFIG_FILE)
292+
OxygenCLI().load_config(CONFIG_FILE)
293+
print('Oxygen handler configuration reset!')
294+
295+
def print_config(self):
296+
print(f'Using config file: {CONFIG_FILE}')
297+
print(dump_yaml(self.config))
298+
299+
def convert_to_robot_result(self, args):
300+
output_filename = self.get_output_filename(args.get('result_file'))
238301
parsed_results = args['func'](
239302
**{k: v for (k, v) in args.items() if not callable(v)})
240303
robot_suite = RobotInterface().running.build_suite(parsed_results)
@@ -243,6 +306,38 @@ def run(self):
243306
report=None,
244307
stdout=StringIO())
245308

309+
def run(self):
310+
parser = ArgumentParser(prog='oxygen')
311+
self.add_arguments(parser)
312+
args = self.parse_args(parser)
313+
match args:
314+
case {'add_config': new_config_path} if new_config_path is not None:
315+
return self.append_config(new_config_path)
316+
case {'print_config': should_print} if should_print:
317+
return self.print_config()
318+
case {'add_config': _,
319+
'reset_config': _,
320+
'print_config': _,
321+
**rest} if not rest: # user is not trying to invoke main-level arguments, but do not provide other arguments either
322+
parser.error('No arguments given')
323+
case _:
324+
# filter out arguments meant for other cases so that downstream
325+
# handler does not need to know about them
326+
filter_list = [v['dest'] for v in
327+
self.MAIN_LEVEL_CLI_ARGS.values()]
328+
filtered_args = {k: v for k, v in args.items()
329+
if k not in filter_list}
330+
return self.convert_to_robot_result(filtered_args)
331+
332+
def main():
333+
'''Main CLI entrypoint
334+
335+
Also used in __main__.py
336+
'''
337+
if '--reset-config' in sys.argv:
338+
OxygenCLI.reset_config()
339+
sys.exit(0)
340+
OxygenCLI().run()
246341

247342
if __name__ == '__main__':
248-
OxygenCLI().run()
343+
main()

tasks.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,16 @@ def install(context, package=None):
3434
@task(iterable=['test'],
3535
help={
3636
'test': 'Limit unit test execution to specific tests. Must be given '
37-
'multiple times to select several targets. See more: '
38-
'https://github.com/CleanCut/green/blob/master/cli-options.txt#L5',
37+
'multiple times to select several targets.'
3938
})
4039
def utest(context, test=None):
41-
run(f'green {" ".join(test) if test else UNIT_TESTS}',
40+
run(f'pytest {" ".join(test) if test else UNIT_TESTS} -q --disable-warnings',
4241
env={'PYTHONPATH': str(SRCPATH)},
4342
pty=(not system() == 'Windows'))
4443

4544
@task
4645
def coverage(context):
47-
run(f'green -r {str(UNIT_TESTS)}',
46+
run(f'pytest --cov {UNIT_TESTS}',
4847
env={'PYTHONPATH': str(SRCPATH)},
4948
pty=(not system() == 'Windows'))
5049
run('coverage html')

0 commit comments

Comments
 (0)