diff --git a/atest/genrunner.py b/atest/genrunner.py old mode 100755 new mode 100644 diff --git a/atest/resources/TestCheckerLibrary.py b/atest/resources/TestCheckerLibrary.py index 3ec3df4d0cf..f661a4838f0 100644 --- a/atest/resources/TestCheckerLibrary.py +++ b/atest/resources/TestCheckerLibrary.py @@ -4,7 +4,7 @@ from robot import utils from robot.api import logger from robot.utils.asserts import assert_equal -from robot.result import (ExecutionResultBuilder, For, If, ForIteration, Keyword, +from robot.result import (XmlExecutionResultBuilder, For, If, ForIteration, Keyword, Result, ResultVisitor, TestCase, TestSuite) from robot.result.model import Body, ForIterations, IfBranches, IfBranch from robot.libraries.BuiltIn import BuiltIn @@ -71,7 +71,7 @@ def process_output(self, path): try: logger.info("Processing output '%s'." % path) result = Result(root_suite=NoSlotsTestSuite()) - ExecutionResultBuilder(path).build(result) + XmlExecutionResultBuilder(path).build(result) except: set_suite_variable('$SUITE', None) msg, details = utils.get_error_details() diff --git a/atest/robot/keywords/embedded_arguments_library_keywords.robot b/atest/robot/keywords/embedded_arguments_library_keywords.robot old mode 100755 new mode 100644 diff --git a/atest/run.py b/atest/run.py old mode 100755 new mode 100644 diff --git a/atest/testdata/keywords/embedded_arguments_library_keywords.robot b/atest/testdata/keywords/embedded_arguments_library_keywords.robot old mode 100755 new mode 100644 diff --git a/atest/testdata/keywords/resources/embedded_args_in_lk_1.py b/atest/testdata/keywords/resources/embedded_args_in_lk_1.py old mode 100755 new mode 100644 diff --git a/atest/testdata/keywords/resources/embedded_args_in_lk_2.py b/atest/testdata/keywords/resources/embedded_args_in_lk_2.py old mode 100755 new mode 100644 diff --git a/atest/testdata/running/stopping_with_signal/Library.py b/atest/testdata/running/stopping_with_signal/Library.py old mode 100755 new mode 100644 diff --git a/atest/testdata/standard_libraries/process/files/non_terminable.py b/atest/testdata/standard_libraries/process/files/non_terminable.py old mode 100755 new mode 100644 diff --git a/atest/testdata/standard_libraries/process/files/script.py b/atest/testdata/standard_libraries/process/files/script.py old mode 100755 new mode 100644 diff --git a/atest/testdata/test_libraries/dynamic_libraries/EmbeddedArgs.py b/atest/testdata/test_libraries/dynamic_libraries/EmbeddedArgs.py old mode 100755 new mode 100644 diff --git a/atest/testresources/compile_java.sh b/atest/testresources/compile_java.sh old mode 100755 new mode 100644 diff --git a/doc/api/generate.py b/doc/api/generate.py old mode 100755 new mode 100644 diff --git a/doc/libraries/extract_examples.py b/doc/libraries/extract_examples.py old mode 100755 new mode 100644 diff --git a/doc/schema/robot-10.schema.json b/doc/schema/robot-10.schema.json new file mode 100644 index 00000000000..4732d85825a --- /dev/null +++ b/doc/schema/robot-10.schema.json @@ -0,0 +1,247 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "suite": { + "type": "object", + "properties": { + "setup": {"$ref": "#/definitions/keyword"}, + "teardown": {"$ref": "#/definitions/keyword"}, + "suites": { + "type": "array", + "items": {"$ref": "#/definitions/suite"}, + "minItems": 0 + }, + "tests": { + "type": "array", + "items": {"$ref": "#/definitions/test"}, + "minItems": 0 + }, + "doc": {"type": "string"}, + "metadata": { + "type": "object", + "additionalProperties": true + }, + "status": {"$ref": "#/definitions/status"}, + "name" : { + "type": "string" + }, + "id": { + "type": "string" + }, + "source":{ + "type": "string" + } + }, + "required": ["name", "status"], + "additionalProperties": true + }, + "test": { + "type": "object", + "properties": { + "body": {"$ref": "#/definitions/body"}, + "doc": {"type": "string"}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 0 + }, + "timeout": {"type": "string"}, + "status": {"$ref": "#/definitions/status"}, + "name" : { + "type": "string" + }, + "id" : { + "type": "string" + } + }, + "required": ["name", "status"], + "additionalProperties": true + }, + "type": { + "type": "string", + "enum": ["kw", "setup", "teardown", "foritem", "for", "if", "elseif", "else"] + }, + "keyword": { + "type": "object", + "properties": { + "body": {"$ref": "#/definitions/body"}, + "msgs": { + "type": "array", + "items": {"$ref": "#/definitions/message"}, + "minItems": 0 + }, + "doc": {"type": "string"}, + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 0 + }, + "timeout": {"type": "string"}, + "args": { + "type": "array", + "items": {"type": "string"}, + "minItems": 0 + }, + "lib": {"type": "string"}, + "var": { + "type": "array", + "items": {"type": "string"}, + "minItems": 0 + }, + "status": {"$ref": "#/definitions/status"}, + "name" : { + "type": "string" + }, + "sourcename" : { + "type": "string" + }, + "type" : {"$ref": "#/definitions/type"} + }, + "required": ["name", "status"], + "additionalProperties": true + }, + "status": { + "type": "object", + "properties": { + "status": {"$ref": "#/definitions/status_enum"}, + "endtime": {"type": "string"}, + "starttime": {"type": "string"}, + "elapsedtime": {"type": "string"}, + "critical": {"type": "boolean"}, + "msg": {"type": "string"} + }, + "required": ["status"], + "additionalProperties": true + }, + "message": { + "type": "object", + "properties": { + "msg": {"type": "string"}, + "timestamp": {"type": "string"}, + "level": {"$ref": "#/definitions/level_enum"}, + "html": {"type": "boolean"} + }, + "required": ["msg", "timestamp", "level"], + "additionalProperties": true + }, + "metadata": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"} + } + }, + "for": { + "type": "object", + "properties": { + "flavor": {"type": "string"}, + "var": {"type": "array", + "items": {"type": "string"}}, + "value": {"type": "array", + "items": {"type": "string"}}, + "status": {"$ref": "#/definitions/status"}, + "doc": {"type": "string"}, + "type" : {"$ref": "#/definitions/type"}, + "iter": {"type": "array", + "items": {"$ref": "#/definitions/iter"}} + } + }, + "itervar": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"} + } + }, + "iter": { + "type": "object", + "properties": { + "var": {"type": "array", + "items": {"$ref": "#/definitions/itervar"}}, + "status": {"$ref": "#/definitions/status"}, + "doc": {"type": "string"}, + "body": {"$ref": "#/definitions/body"} + } + }, + "if": { + "type": "object", + "properties": { + "doc": {"type": "string"}, + "status": {"$ref": "#/definitions/status"}, + "type" : {"$ref": "#/definitions/type"}, + "branches": {"type": "array", + "items": {"$ref": "#/definitions/branch"}} + } + }, + "branch": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "condition": {"type": "string"}, + "doc": {"type": "string"}, + "status": {"$ref": "#/definitions/status"}, + "body": {"$ref": "#/definitions/body"} + } + }, + "body": { + "type": "array", + "items": {"oneOf": [{"$ref": "#/definitions/keyword"}, + {"$ref": "#/definitions/for"}, + {"$ref": "#/definitions/if"}, + {"$ref": "#/definitions/message"}]}, + "minItems": 0 + }, + "statistics": { + "type": "object", + "properties": { + "total": {"$ref": "#/definitions/stats"}, + "tag": {"$ref": "#/definitions/stats"}, + "suite": {"$ref": "#/definitions/stats"} + }, + "required": ["total", "tag", "suite"] + }, + "stats": { + "type": "array", + "items": {"$ref": "#/definitions/stat"}, + "minItems": 0 + }, + "stat": { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "fail": {"type": "number"}, + "skip": {"type": "number"}, + "critical": {"type": "boolean"}, + "pass": {"type": "number"}, + "info": {"type": "string"}, + "doc": {"type": "string"}, + "id": {"type": "string"}, + "name": {"type": "string"} + }, + "additionalProperties": true + }, + "errors": { + "type": "array", + "items": {"$ref": "#/definitions/message"} + }, + "status_enum": { + "type": "string", + "enum": ["PASS", "FAIL"] + }, + "level_enum": { + "type": "string", + "enum": ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FAIL"] + } + }, + "type": "object", + "properties": { + "generator": {"type": "string"}, + "generated": {"type": "string"}, + "rpa": {"type": "boolean"}, + "suite": {"$ref": "#/definitions/suite"}, + "statistics": {"$ref": "#/definitions/statistics"}, + "errors": {"$ref": "#/definitions/errors"} + }, + "required": ["suite"], + "additionalProperties": true +} \ No newline at end of file diff --git a/doc/userguide/ug2html.py b/doc/userguide/ug2html.py old mode 100755 new mode 100644 diff --git a/rundevel.py b/rundevel.py old mode 100755 new mode 100644 diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 diff --git a/src/robot/__main__.py b/src/robot/__main__.py old mode 100755 new mode 100644 diff --git a/src/robot/conf/settings.py b/src/robot/conf/settings.py index 97caeebe0d2..08022f8a1fd 100644 --- a/src/robot/conf/settings.py +++ b/src/robot/conf/settings.py @@ -49,6 +49,7 @@ class _BaseSettings(object): 'Log' : ('log', 'log.html'), 'Report' : ('report', 'report.html'), 'XUnit' : ('xunit', None), + 'Json' : ('json', False), 'SplitLog' : ('splitlog', False), 'TimestampOutputs' : ('timestampoutputs', False), 'LogTitle' : ('logtitle', None), @@ -203,7 +204,7 @@ def __getitem__(self, name): def _get_output_file(self, option): """Returns path of the requested output file and creates needed dirs. - `option` can be 'Output', 'Log', 'Report', 'XUnit' or 'DebugFile'. + `option` can be 'Output', 'Log', 'Report', 'XUnit', or 'DebugFile'. """ name = self._opts[option] if not name: @@ -212,6 +213,8 @@ def _get_output_file(self, option): self['Log'] = None LOGGER.error('Log file is not created if output.xml is disabled.') return None + if option == 'Output' and self.json is True and name == 'output.xml': + name = 'output.json' name = self._process_output_name(option, name) path = abspath(os.path.join(self['OutputDir'], name)) create_destination_directory(path, '%s file' % option.lower()) @@ -334,6 +337,10 @@ def report(self): def xunit(self): return self['XUnit'] + @property + def json(self): + return self['Json'] + @property def log_level(self): return self['LogLevel'] @@ -421,6 +428,7 @@ def get_rebot_settings(self): for name in ['Name', 'Doc']: settings._opts[name] = None settings._opts['Output'] = None + settings._opts['Json'] = None settings._opts['LogLevel'] = 'TRACE' settings._opts['ProcessEmptySuite'] = self['RunEmptySuite'] settings._opts['ExpandKeywords'] = self['ExpandKeywords'] @@ -553,6 +561,11 @@ class RebotSettings(_BaseSettings): 'EndTime' : ('endtime', None), 'Merge' : ('merge', False)} + def __init__(self, options=None, **extra_options): + # When using rebot Json is an output argument, rather than a flag + self._output_opts.append('Json') + super(RebotSettings, self).__init__(options, **extra_options) + def _output_disabled(self): return False diff --git a/src/robot/htmldata/testdata/create_jsdata.py b/src/robot/htmldata/testdata/create_jsdata.py old mode 100755 new mode 100644 diff --git a/src/robot/htmldata/testdata/create_libdoc_data.py b/src/robot/htmldata/testdata/create_libdoc_data.py old mode 100755 new mode 100644 diff --git a/src/robot/htmldata/testdata/create_testdoc_data.py b/src/robot/htmldata/testdata/create_testdoc_data.py old mode 100755 new mode 100644 diff --git a/src/robot/libdoc.py b/src/robot/libdoc.py old mode 100755 new mode 100644 diff --git a/src/robot/libdocpkg/consoleviewer.py b/src/robot/libdocpkg/consoleviewer.py old mode 100755 new mode 100644 diff --git a/src/robot/output/jsonlogger.py b/src/robot/output/jsonlogger.py new file mode 100644 index 00000000000..3202ca89504 --- /dev/null +++ b/src/robot/output/jsonlogger.py @@ -0,0 +1,537 @@ +from robot.utils import get_timestamp, unic +from robot.version import get_full_version +from robot.result.visitor import ResultVisitor +from robot.errors import DataError + +from .loggerhelper import IsLogged + +import enum +import json +import copy + +# Temporary fix to not break RF +try: + import jsonstreams +except ImportError: + jsonstreams = None + + +class RobotElement(object): + def __init__(self, subobject, elem_type, destination, elems=None): + # Store the object we're writing into + self._subobject = subobject + self._body = None + # Store the type & destination for the object + self.destination = destination + self.type = elem_type + if elems: + # Store the elements initially given into the subobject + for key, value in elems.items(): + self[key] = value + + def __setitem__(self, key, value): + # Ensure that the values aren't None, empty string, or empty list + # Cannot do "if value" because this would ignore "False" booleans + if value is not None and value != '' and value != []: + self._subobject.write(key, value) + + def body(self): + return self._body if self._body else self.create_body() + + def subobject(self, key=None): + if key: + return self._subobject.subobject(key) + else: + return self._subobject.subobject() + + def subarray(self, key): + return self._subobject.subarray(key) + + def make_body(self): + self._body = self._subobject.subarray('body') + return self._body + + def make_branches(self): + self._body = self._subobject.subarray('branches') + return self._body + + def make_iter(self): + self._body = self._subobject.subarray('iter') + return self._body + + def create_body(self, ): + if self.type == Items.IF_: + return self.make_branches() + elif self.type == Items.TEST or \ + self.type == Items.BODY: + return self.make_body() + elif self.type == Items.FOR_: + return self.make_iter() + else: + raise ValueError("Bad element type attempting to make body") + + def close_body(self): + if self._body: + self._body.close() + self._body = None + + def close(self): + if self._body: + self._body.close() + try: + self._subobject.close() + except jsonstreams.ModifyWrongStreamError: + # Already closed + pass + + +class RobotSuiteElement(RobotElement): + def __init__(self, subobject, elem_type, destination, elems=None): + super(RobotSuiteElement, self).__init__(subobject, elem_type, destination, elems) + self._suites = None + self._tests = None + + def make_suites(self): + self._suites = self._subobject.subarray('suites') + return self._suites + + def suites(self): + return self._suites if self._suites else self.make_suites() + + def make_tests(self): + self._tests = self._subobject.subarray('tests') + return self._tests + + def tests(self): + return self._tests if self._tests else self.make_tests() + + def close_body(self): + self.close_tests() + + def close_tests(self): + if self._tests: + self._tests.close() + self._tests = None + + def close_suites(self): + if self._suites: + self._suites.close() + self._suites = None + + def close(self): + if self._suites: + self._suites.close() + if self._tests: + self._tests.close() + super(RobotSuiteElement, self).close() + + +class Items(enum.Enum): + SUITE = "suite" + TEST = "test" + IF_ = "if" + FOR_ = "for" + BODY = "body" + STATS = "stats" + STAT = "stat" + TOTAL = "total" + TAG = "tag" + SUITE_STATS = "suite" + ERRORS = "errors" + + + +RECURSIVE_ITEMS = [Items.IF_, Items.FOR_, Items.BODY] + + + +class JsonLogger(ResultVisitor): + + def __init__(self, path, log_level='TRACE', rpa=False, generator='Robot'): + if not jsonstreams: + raise DataError('Using the JSON output format requires the ' + 'jsonstreams module to be installed. Typically ' + 'you can install it by running ' + '`pip install jsonstreams`.') + + self._log_message_is_logged = IsLogged(log_level) + self._error_message_is_logged = IsLogged('WARN') + self._path = path + + # Setup the JSON data to store before writing the file + self._data = { + 'rpa': rpa is not None, + 'generator': get_full_version(generator), + 'generated': get_timestamp() + } + self._errors = [] + + # Setup stacks + self._item_stack = list() + + # We need to keep track of the active suite, test, and body item + self._suite = None + self._body = None + self._body_item = None + self._test = None + self._errors_element = None + + # We need to be able to track the type of item being processed + # at any moment + self._item_type = None + + self._root = jsonstreams.Stream(jsonstreams.Type.object, filename=self._path) + self._root.write('rpa', rpa is not None) + self._root.write('generator', get_full_version(generator)) + self._root.write('generated', get_timestamp()) + + def _format_data(self, item): + return {key: value for key, value in item.items() if value} + + def open_item(self): + if self._item_type in RECURSIVE_ITEMS: + if self._body_item: + # Push this onto the stack + subobject = self._body_item.body().subobject() + self._item_stack.append(self._body_item) + else: + subobject = self._item_stack[-1].body().subobject() + # If there is no current item then we're running inside of test case + else: + subobject = self._test.body().subobject() + self._item_type = Items.TEST + return subobject + + def close_item(self): + # Get the items destination + destination = self._body_item.destination + # Close the ite, + self._body_item.close() + # Remove any reference to the item + self._body_item = None + # Mark down the destination type + if destination in RECURSIVE_ITEMS: + self._item_type = destination + else: + self._item_type = None + + def _create_status(self, data): + status = { + 'status': data.status + } + if data.starttime: + status['starttime'] = data.starttime + if data.endtime: + status['endtime'] = data.endtime + if str(data.elapsedtime): + status['elapsedtime'] = str(data.elapsedtime) + return status + + def close(self): + # Create the errors + if self._errors: + self.start_errors() + messages = [self._create_message(msg) for msg in self._errors] + self.end_errors(messages) + # Close the root object (and hence the file) + self._root.close() + + def set_log_level(self, level): + return self._log_message_is_logged.set_level(level) + + def message(self, msg): + if self._error_message_is_logged(msg.level): + self._errors.append(msg) + + def log_message(self, msg): + if self._log_message_is_logged(msg.level): + self._write_message(msg) + + def _write_message(self, msg): + # Decipher where the message belongs + if self._item_type in RECURSIVE_ITEMS: + if self._body_item: + item = self._body_item + elif not self._body_item: + item = self._item_stack[-1] + else: + item = self._test + + # Make the message + message = self._create_message(msg) + + # Attach the message to the body + RobotElement(item.body().subobject(), None, None, message).close() + + + def _create_message(self, msg): + message = { + 'msg': msg.message, + 'level': msg.level, + 'timestamp': msg.timestamp or 'N/A', + 'type': msg.type + } + if msg.html: + message['html'] = 'true' + return message + + def start_keyword(self, kw): + # Only a suite will not store a keyword in its body. + # This is because for tests and setups to store them + # differently would require more advanced JSON streaming + subobject = None + # Setup keywords can be emitted by Test cases and Suite + if kw.type == 'SETUP' and not self._test and self._suite: + subobject = self._suite.subobject('setup') + # Teardown keywords can be emitted by Test cases, suites, and keywords + if kw.type == 'TEARDOWN': + # If the item stack is empty, or the item on the end of the stack is + # not a BODY type and there is no test activate and the suite is active + if (not self._item_stack or not self._item_stack[-1].type == Items.BODY) \ + and not self._test and self._suite: + # Close the body of the suite, since the teardown is being called + # the suite + self._suite.close_body() + subobject = self._suite.subobject('teardown') + if subobject is None: + subobject = self.open_item() + + self._body_item = RobotElement(subobject, Items.BODY, copy.deepcopy(self._item_type), { + 'name': kw.kwname, + 'lib': kw.libname, + 'type': kw.type if kw.type != 'KEYWORD' else None, + 'doc': kw.doc, + 'tags': [unic(t) for t in kw.tags], + 'args': [unic(a) for a in kw.args], + 'var': [var for var in kw.assign] + }) + # Mark the type of item + self._item_type = Items.BODY + + def end_keyword(self, kw): + # Check if we have an item in progress + if not self._body_item: + # If not, then the keywords have been processed for the item on the stack + self._body_item = self._item_stack.pop() + self._body_item.close_body() + + # Add the rest of the information + if kw.timeout: + self._body_item['timeout'] = unic(kw.timeout) + self._body_item['status'] = self._create_status(kw) + self.close_item() + + def start_if(self, if_): + self._body_item = RobotElement(self.open_item(), Items.IF_, copy.deepcopy(self._item_type), { + 'doc': if_.doc, + 'type': if_.type + }) + # Mark the type of item + self._item_type = Items.IF_ + + def end_if(self, if_): + # Check if we have an item in progress + if not self._body_item: + # If not, then the keywords have been processed for the item on the stack + self._body_item = self._item_stack.pop() + self._body_item.close_body() + + self._body_item['status'] = self._create_status(if_) + self.close_item() + + def start_if_branch(self, branch): + self._body_item = RobotElement(self.open_item(), Items.BODY, copy.deepcopy(self._item_type), { + 'doc': branch.doc, + 'type': branch.type, + 'condition': branch.condition + }) + # Mark the type of item + self._item_type = Items.BODY + + def end_if_branch(self, branch): + # Check if we have an item in progress + if not self._body_item: + # If not, then the keywords have been processed for the item on the stack + self._body_item = self._item_stack.pop() + self._body_item.close_body() + + self._body_item['status'] = self._create_status(branch) + self.close_item() + + def start_for(self, for_): + self._body_item = RobotElement(self.open_item(), Items.FOR_, copy.deepcopy(self._item_type), { + 'doc': for_.doc, + 'flavor': for_.flavor, + 'var': [var for var in for_.variables], + 'value': [value for value in for_.values], + 'type': for_.type + }) + # Mark the type of item + self._item_type = Items.FOR_ + + def end_for(self, for_): + # Check if we have an item in progress + if not self._body_item: + # If not, then the keywords have been processed for the item on the stack + self._body_item = self._item_stack.pop() + self._body_item.close_body() + + self._body_item['status'] = self._create_status(for_) + self.close_item() + + def start_for_iteration(self, iteration): + self._body_item = RobotElement(self.open_item(), Items.BODY, copy.deepcopy(self._item_type), { + 'doc': iteration.doc, + 'var': {name: value for name, value in iteration.variables.items()}, + 'type': iteration.type + }) + # Mark the type of item + self._item_type = Items.BODY + + def end_for_iteration(self, iteration): + # Check if we have an item in progress + if not self._body_item: + # If not, then the keywords have been processed for the item on the stack + self._body_item = self._item_stack.pop() + self._body_item.close_body() + + self._body_item['status'] = self._create_status(iteration) + self.close_item() + + def start_test(self, test): + self._suite.close_suites() + self._test = RobotElement(self._suite.tests().subobject(), Items.TEST, None, { + 'id': test.id, + 'name': test.name + }) + # Mark the type of item (Identify where to place keywords) + self._item_type = Items.TEST + + def end_test(self, test): + self._test.close_body() + self._test['doc'] = test.doc + self._test['tags'] = [unic(t) for t in test.tags] + if test.timeout: + self._test['timeout'] = unic(test.timeout) + self._test['status'] = self._create_status(test) + self._test.close() + self._test = None + + def start_suite(self, suite): + # If there is an "open" suite, this will be placed inside its suites + if self._suite: + subobject = self._suite.suites().subobject() + # Push this onto the stack + self._item_stack.append(self._suite) + elif self._item_stack: + # This should be a suite object on the stack + subobject = self._item_stack[-1].suites().subobject() + else: + subobject = self._root.subobject('suite') + + self._suite = RobotSuiteElement(subobject, Items.SUITE, None, { + 'id': suite.id, + 'name': suite.name, + 'source': suite.source + }) + # Mark the type of item (not all items are placed back in the same place) + self._item_type = Items.SUITE + + def end_suite(self, suite): + # Check if we have an item in progress + if not self._suite: + # If not, then the suites have been processed for the suite on the stack + self._suite = self._item_stack.pop() + self._suite.close_suites() + self._suite.close_tests() + + self._suite['doc'] = suite.doc + self._suite['metadata'] = {key: suite.metadata[key] for key in suite.metadata} + self._suite['status'] = self._create_status(suite) + self._suite.close() + self._suite = None + # Mark the current item as being nothing + self._item_type = "" + + def start_statistics(self, stats): + self._body_item = RobotElement(self._root.subobject('statistics'), Items.STATS, Items.SUITE) + self._item_type = Items.STATS + + def end_statistics(self, stats): + self._body_item.close() + + def start_total_statistics(self, total_stats): + if self._body_item.type != Items.STATS: + raise ValueError("The current item is not set to be a statistic") + subarray = self._body_item.subarray('total') + self._item_stack.append(self._body_item) + self._body_item = RobotElement(subarray, Items.TOTAL, Items.STATS) + self._item_type = Items.TOTAL + + def end_total_statistics(self, total_stats): + if self._item_type != Items.TOTAL: + self._body_item = self._item_stack.pop() + self._body_item.close() + # Pop the stack off of the queue + self._body_item = self._item_stack.pop() + + def start_tag_statistics(self, tag_stats): + if self._body_item.type != Items.STATS: + raise ValueError("The current item is not set to be a statistic") + subarray = self._body_item.subarray('tag') + self._item_stack.append(self._body_item) + self._body_item = RobotElement(subarray, Items.TOTAL, Items.STATS) + self._item_type = Items.TAG + + def end_tag_statistics(self, tag_stats): + if self._item_type != Items.TAG: + self._body_item = self._item_stack.pop() + self._body_item.close() + # Pop the stack off of the queue + self._body_item = self._item_stack.pop() + + def start_suite_statistics(self, tag_stats): + if self._body_item.type != Items.STATS: + raise ValueError("The current item is not set to be a statistic") + subarray = self._body_item.subarray('suite') + self._item_stack.append(self._body_item) + self._body_item = RobotElement(subarray, Items.TOTAL, Items.STATS) + self._item_type = Items.SUITE_STATS + + def end_suite_statistics(self, tag_stats): + if self._item_type != Items.SUITE_STATS: + self._body_item = self._item_stack.pop() + self._body_item.close() + # Pop the stack off of the queue + self._body_item = self._item_stack.pop() + + def visit_stat(self, stat): + subobject = self._body_item.subobject() + stat_json = stat.get_attributes() + stat_json['name'] = stat.name + if self._item_type == Items.TAG: + stat_json['tag'] = stat.name + RobotElement(subobject, Items.STAT, self._item_type, stat_json).close() + + def start_errors(self, errors=None): + self._errors_element = RobotElement(self._root.subarray('errors'), Items.ERRORS, Items.SUITE) + + def end_errors(self, errors=None): + for msg in self._errors: + RobotElement(self._errors_element.subobject(), + Items.BODY, + Items.ERRORS, + self._create_message(msg)).close() + self._errors_element.close() + self._body_item = None + + def _create_statistic(self, stat): + statistic = { + 'name': stat.name, + 'passed': stat.passed, + 'failed': stat.failed + } + suite_id = getattr(stat, 'id', None) + if suite_id: + statistic['id'] = suite_id + return statistic diff --git a/src/robot/output/logger.py b/src/robot/output/logger.py index 2ee8a63ca91..8448a505aa3 100644 --- a/src/robot/output/logger.py +++ b/src/robot/output/logger.py @@ -109,7 +109,7 @@ def register_syslog(self, path=None, level='INFO'): else: self._syslog = self._wrap_and_relay(syslog) - def register_xml_logger(self, logger): + def register_output_logger(self, logger): self._xml_logger = self._wrap_and_relay(logger) def unregister_xml_logger(self): diff --git a/src/robot/output/output.py b/src/robot/output/output.py index 88a796e1e2b..d2c89d033b8 100644 --- a/src/robot/output/output.py +++ b/src/robot/output/output.py @@ -19,21 +19,24 @@ from .logger import LOGGER from .loggerhelper import AbstractLogger from .xmllogger import XmlLogger +from .jsonlogger import JsonLogger class Output(AbstractLogger): def __init__(self, settings): AbstractLogger.__init__(self) - self._xmllogger = XmlLogger(settings.output, settings.log_level, - settings.rpa) + if str(settings.output).lower().endswith(".json") or settings.json: + self._outputlogger = JsonLogger(settings.output, settings.log_level, settings.rpa) + else: + self._outputlogger = XmlLogger(settings.output, settings.log_level, settings.rpa) self.listeners = Listeners(settings.listeners, settings.log_level) self.library_listeners = LibraryListeners(settings.log_level) self._register_loggers(DebugFile(settings.debug_file)) self._settings = settings def _register_loggers(self, debug_file): - LOGGER.register_xml_logger(self._xmllogger) + LOGGER.register_output_logger(self._outputlogger) LOGGER.register_listeners(self.listeners or None, self.library_listeners) if debug_file: LOGGER.register_logger(debug_file) @@ -42,8 +45,8 @@ def register_error_listener(self, listener): LOGGER.register_error_listener(listener) def close(self, result): - self._xmllogger.visit_statistics(result.statistics) - self._xmllogger.close() + self._outputlogger.visit_statistics(result.statistics) + self._outputlogger.close() LOGGER.unregister_xml_logger() LOGGER.output_file('Output', self._settings['Output']) @@ -72,4 +75,4 @@ def set_log_level(self, level): pyloggingconf.set_level(level) self.listeners.set_log_level(level) self.library_listeners.set_log_level(level) - return self._xmllogger.set_log_level(level) + return self._outputlogger.set_log_level(level) diff --git a/src/robot/output/xmllogger.py b/src/robot/output/xmllogger.py index 863039cab56..278e72a3822 100644 --- a/src/robot/output/xmllogger.py +++ b/src/robot/output/xmllogger.py @@ -40,7 +40,7 @@ def _get_writer(self, path, rpa, generator): def close(self): self.start_errors() for msg in self._errors: - self._write_message(msg) + self.message(msg) self.end_errors() self._writer.end('robot') self._writer.close() diff --git a/src/robot/rebot.py b/src/robot/rebot.py old mode 100755 new mode 100644 index cd1041c3194..f660785ae62 --- a/src/robot/rebot.py +++ b/src/robot/rebot.py @@ -133,7 +133,8 @@ -o --output file XML output file. Not created unless this option is specified. Given path, similarly as paths given to --log, --report and --xunit, is relative to - --outputdir unless given as an absolute path. + --outputdir unless given as an absolute path. If + the file ends in .json then the JSON will be loaded. -l --log file HTML log file. Can be disabled by giving a special name `NONE`. Default: log.html Examples: `--log mylog.html`, `-l none` @@ -141,6 +142,8 @@ similarly as --log. Default: report.html -x --xunit file xUnit compatible result file. Not created unless this option is specified. + -j --json file A JSON result file. Not created unless this option is + specified. --xunitskipnoncritical Deprecated since RF 4.0 and has no effect anymore. -T --timestampoutputs When this option is used, timestamp in a format `YYYYMMDD-hhmmss` is added to all generated output diff --git a/src/robot/reporting/jsonwriter.py b/src/robot/reporting/jsonwriter.py new file mode 100644 index 00000000000..b3ad05b6982 --- /dev/null +++ b/src/robot/reporting/jsonwriter.py @@ -0,0 +1,23 @@ +from robot.output.jsonlogger import JsonLogger + + +class JsonOutputWriter(JsonLogger): + + def __init__(self, output, rpa=False): + JsonLogger.__init__(self, output, rpa=rpa, generator='Rebot') + + def start_message(self, msg): + self.log_message(msg) + + def end_result(self, result): + self.close() + + +class JsonWriter(object): + + def __init__(self, execution_result): + self._execution_result = execution_result + + def write(self, output): + writer = JsonOutputWriter(output) + self._execution_result.visit(writer) diff --git a/src/robot/reporting/resultwriter.py b/src/robot/reporting/resultwriter.py index e0ae3ec3cd0..c4af2ce4dc3 100644 --- a/src/robot/reporting/resultwriter.py +++ b/src/robot/reporting/resultwriter.py @@ -23,6 +23,7 @@ from .jsmodelbuilders import JsModelBuilder from .logreportwriters import LogWriter, ReportWriter from .xunitwriter import XUnitWriter +from .jsonwriter import JsonWriter class ResultWriter(object): @@ -66,6 +67,8 @@ def write_results(self, settings=None, **options): results.js_result.remove_data_not_needed_in_report() self._write_report(results.js_result, settings.report, settings.report_config) + if settings.json: + self._write_json(results.result, settings.json) return results.return_code def _write_output(self, result, path): @@ -80,6 +83,9 @@ def _write_log(self, js_result, path, config): def _write_report(self, js_result, path, config): self._write('Report', ReportWriter(js_result).write, path, config) + def _write_json(self, result, path): + self._write('Json', JsonWriter(result).write, path) + def _write(self, name, writer, path, *args): try: writer(path, *args) diff --git a/src/robot/result/__init__.py b/src/robot/result/__init__.py index 8b45be5de23..e48313f72c1 100644 --- a/src/robot/result/__init__.py +++ b/src/robot/result/__init__.py @@ -42,6 +42,6 @@ """ from .executionresult import Result +from .resultbuilder import ExecutionResult, XmlExecutionResultBuilder from .model import For, If, IfBranch, ForIteration, Keyword, Message, TestCase, TestSuite -from .resultbuilder import ExecutionResult, ExecutionResultBuilder from .visitor import ResultVisitor diff --git a/src/robot/result/executionresult.py b/src/robot/result/executionresult.py index ecb2bff9280..3b56268dfaf 100644 --- a/src/robot/result/executionresult.py +++ b/src/robot/result/executionresult.py @@ -96,14 +96,22 @@ def configure(self, status_rc=True, suite_config=None, stat_config=None): self._status_rc = status_rc self._stat_config = stat_config or {} - def save(self, path=None): + def save(self, path=None, json=False): """Save results as a new output XML file. :param path: Path to save results to. If omitted, overwrites the original file. + :param json: A flag to represent if the JSON format should be used + or not. """ from robot.reporting.outputwriter import OutputWriter - self.visit(OutputWriter(path or self.source, rpa=self.rpa)) + from robot.reporting.jsonwriter import JsonOutputWriter + output_path = path or self.source + if json or output_path.upper().endswith("JSON"): + writer = JsonOutputWriter(output_path, rpa=self.rpa) + else: + writer = OutputWriter(output_path, rpa=self.rpa) + self.visit(writer) def visit(self, visitor): """An entry point to visit the whole result object. diff --git a/src/robot/result/jsonelementhandlers.py b/src/robot/result/jsonelementhandlers.py new file mode 100644 index 00000000000..9312ef87099 --- /dev/null +++ b/src/robot/result/jsonelementhandlers.py @@ -0,0 +1,310 @@ +# Copyright 2008-2015 Nokia Networks +# Copyright 2016- Robot Framework Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from robot.errors import DataError +from .model import TestSuite + +class JsonElementHandler(object): + + def __init__(self, execution_result): + self._result = execution_result + + def parse(self, json_data): + parse_robot(json_data, self._result) + + +def parse_robot(root_element, result): + ElementHandler.element_handlers.get('robot').parse(root_element, result) + + +class ElementHandler(object): + element_handlers = {} + list_children = frozenset() + children = frozenset() + + @classmethod + def register(cls, handler): + # Because JSON differentiates between lists and single + # objects some handlers will have multiple tags + for tag in handler.tags: + cls.element_handlers[tag] = handler() + return handler + + def parse(self, elem, result): + handler_result = self.start(elem, result) + for child_list in self.list_children: + if child_list not in elem: + continue + for child in elem[child_list]: + self.element_handlers[child_list].parse(child, handler_result) + for child in self.children: + if child not in elem: + continue + self.element_handlers[child].parse(elem[child], handler_result) + self.end(elem, handler_result) + + + def start(self, elem, result): + return result + + def end(self, elem, result): + pass + + def _timestamp(self, elem, attr_name): + timestamp = elem.get(attr_name) + return timestamp if timestamp != 'N/A' else None + + +@ElementHandler.register +class RobotHandler(ElementHandler): + tags = ['robot'] + children = frozenset(('suite', 'errors')) + + def start(self, elem, result): + generator = elem.get('generator', 'unknown').split()[0].upper() + result.generated_by_robot = generator == 'ROBOT' + if result.rpa is None: + result.rpa = elem.get('rpa', 'false') == 'true' + return result + + +@ElementHandler.register +class SuiteHandler(ElementHandler): + tags = ['suite', 'suites'] + list_children = frozenset(('kw', 'tests', 'suites')) + children = frozenset(('doc', 'metadata', 'status', 'setup', 'teardown')) + + def start(self, elem, result): + if hasattr(result, 'suite'): # root + return result.suite.config(name=elem.get('name', ''), + source=elem.get('source'), + rpa=result.rpa) + return result.suites.create(name=elem.get('name', ''), + source=elem.get('source'), + rpa=result.rpa) + + +@ElementHandler.register +class TestHandler(ElementHandler): + tags = ['tests'] + list_children = frozenset(('body', 'tags')) + children = frozenset(('doc', 'timeout', 'status', 'msg')) + + def start(self, elem, result): + return result.tests.create(name=elem.get('name', '')) + + +@ElementHandler.register +class KeywordHandler(ElementHandler): + tags = ['kw', 'teardown', 'setup'] + list_children = frozenset(('body', 'msgs', 'tags', 'args', 'var')) + children = frozenset(('doc', 'timeout', 'status')) + + def start(self, elem, result): + elem_type = elem.get('type') + if not elem_type: + creator = self._create_keyword + else: + creator = getattr(self, '_create_%s' % elem_type.lower().replace(' ', '_')) + return creator(elem, result) + + def _create_keyword(self, elem, result): + return result.body.create_keyword(kwname=elem.get('name', ''), + libname=elem.get('lib')) + + def _create_setup(self, elem, result): + return result.setup.config(kwname=elem.get('name', ''), + libname=elem.get('lib')) + + def _create_teardown(self, elem, result): + return result.teardown.config(kwname=elem.get('name', ''), + libname=elem.get('lib')) + + # RF < 4 compatibility. + + def _create_for(self, elem, result): + return result.body.create_keyword(kwname=elem.get('name'), type='FOR') + + def _create_foritem(self, elem, result): + return result.body.create_keyword(kwname=elem.get('name'), type='FOR ITERATION') + + _create_for_iteration = _create_foritem + + +@ElementHandler.register +class BodyHandler(ElementHandler): + tags = ['body'] + + def parse(self, elem, result): + body_type = elem['type'] if 'type' in elem else 'KW' + if body_type == 'KW' or body_type == "SETUP" or body_type == "TEARDOWN": + self.element_handlers['kw'].parse(elem, result) + elif body_type == 'FOR': + self.element_handlers['for'].parse(elem, result) + elif body_type == 'IF/ELSE ROOT': + self.element_handlers['if'].parse(elem, result) + elif body_type == 'MESSAGE': + self.element_handlers['msg'].parse(elem, result) + + +@ElementHandler.register +class ForHandler(ElementHandler): + tags = ['for'] + list_children = frozenset(('iter', 'var', 'value', 'msgs')) + children = frozenset(('doc', 'status')) + + def start(self, elem, result): + return result.body.create_for(flavor=elem.get('flavor')) + + +@ElementHandler.register +class ForIterationHandler(ElementHandler): + tags = ['iter'] + list_children = frozenset(('body', 'msgs')) + children = frozenset(('doc', 'status', 'var')) + + def start(self, elem, result): + return result.body.create_iteration() + + +@ElementHandler.register +class IfHandler(ElementHandler): + tags = ['if'] + list_children = frozenset(('branches', 'msgs')) + children = frozenset(('status', 'msg')) + + def start(self, elem, result): + return result.body.create_if() + + +@ElementHandler.register +class IfBranchHandler(ElementHandler): + tags = ['branch', 'branches'] + list_children = frozenset(('body', 'msgs')) + children = frozenset(('status', 'msg')) + + def start(self, elem, result): + return result.body.create_branch(elem.get('type'), elem.get('condition')) + + +@ElementHandler.register +class MessageHandler(ElementHandler): + tags = ['msg', 'msgs'] + + def end(self, elem, result): + html_true = ('true', 'yes') # 'yes' is compatibility for RF < 4. + result.body.create_message(elem.get('msg', ''), + elem.get('level', 'INFO'), + elem.get('html') in html_true, + self._timestamp(elem, 'timestamp')) + + +@ElementHandler.register +class StatusHandler(ElementHandler): + tags = ['status'] + + def end(self, elem, result): + if not isinstance(result, TestSuite): + result.status = elem.get('status', 'FAIL') + result.starttime = self._timestamp(elem, 'starttime') + result.endtime = self._timestamp(elem, 'endtime') + message = elem.get('msg') + if message: + result.message = message + + +@ElementHandler.register +class DocHandler(ElementHandler): + tags = ['doc'] + + def end(self, elem, result): + result.doc = elem or '' + + +@ElementHandler.register +class MetaHandler(ElementHandler): + tags = ['metadata'] + + def end(self, elem, result): + result.metadata[elem.get('name', '')] = elem.get('value', '') + + +@ElementHandler.register +class TagHandler(ElementHandler): + tags = ['tag', 'tags'] + + def end(self, elem, result): + result.tags.add(elem or '') + + +@ElementHandler.register +class TimeoutHandler(ElementHandler): + tags = ['timeout'] + + def end(self, elem, result): + result.timeout = elem + + +@ElementHandler.register +class VarHandler(ElementHandler): + tags = ['var', 'vars'] + + def end(self, elem, result): + if result.type == result.KEYWORD: + result.assign += (elem,) + elif result.type == result.FOR: + result.variables += (elem,) + elif result.type == result.FOR_ITERATION: + for name, value in elem.items(): + result.variables[name] = value + else: + raise DataError("Invalid element '%s' for result '%r'." % (elem, result)) + + +@ElementHandler.register +class ArgumentHandler(ElementHandler): + tags = ['arg', 'args'] + + def end(self, elem, result): + result.args += (elem or '',) + + +@ElementHandler.register +class ValueHandler(ElementHandler): + tags = ['value'] + + def end(self, elem, result): + result.values += (elem or '',) + + +@ElementHandler.register +class ErrorsHandler(ElementHandler): + tags = ['errors'] + + def start(self, elem, result): + return result.errors + + def get_child_handler(self, tag): + return ErrorMessageHandler() + + +class ErrorMessageHandler(ElementHandler): + + def end(self, elem, result): + html_true = ('true', 'yes') # 'yes' is compatibility for RF < 4. + result.messages.create(elem.text or '', + elem.get('level', 'INFO'), + elem.get('html') in html_true, + self._timestamp(elem, 'timestamp')) diff --git a/src/robot/result/resultbuilder.py b/src/robot/result/resultbuilder.py index bb55f2725d5..f1fb8f591b4 100644 --- a/src/robot/result/resultbuilder.py +++ b/src/robot/result/resultbuilder.py @@ -22,7 +22,10 @@ FlattenByTagMatcher) from .merger import Merger from .xmlelementhandlers import XmlElementHandler +from .jsonelementhandlers import JsonElementHandler +import json +import traceback def ExecutionResult(*sources, **options): """Factory method to constructs :class:`~.executionresult.Result` objects. @@ -66,18 +69,93 @@ def _combine_results(sources, options): def _single_result(source, options): - ets = ETSource(source) result = Result(source, rpa=options.pop('rpa', None)) - try: - return ExecutionResultBuilder(ets, **options).build(result) - except IOError as err: - error = err.strerror - except: - error = get_error_message() - raise DataError("Reading XML source '%s' failed: %s" % (unic(ets), error)) + if source.upper().endswith("JSON"): + try: + with open(source, 'r') as source_file: + json_data = json.load(source_file) + return JsonExecutionResultBuilder(json_data, **options).build(result) + except IOError as err: + error = err.strerror + except Exception: + traceback.print_exc() + error = get_error_message() + raise DataError("Reading JSON source '%s' failed: %s" % (unic(json_data), error)) + else: + ets = ETSource(source) + try: + return XmlExecutionResultBuilder(ets, **options).build(result) + except IOError as err: + error = err.strerror + except: + error = get_error_message() + raise DataError("Reading XML source '%s' failed: %s" % (unic(ets), error)) -class ExecutionResultBuilder(object): +class JsonExecutionResultBuilder(object): + """Builds :class:`~.executionresult.Result` objects based on output files. + + Instead of using this builder directly, it is recommended to use the + :func:`ExecutionResult` factory method. + """ + def gen_dict_extract(self, key, var): + if hasattr(var,'iteritems'): + for k, v in var.iteritems(): + if k == key: + yield v + if isinstance(v, dict): + for result in self.gen_dict_extract(key, v): + yield result + elif isinstance(v, list): + for d in v: + for result in self.gen_dict_extract(key, d): + yield result + + + def _omit_keywords(self, key, var): + if hasattr(var, 'keys'): + keys = var.keys() + for k in keys: + if k == 'setup': + var.pop(k) + for k, v in var.iteritems(): + if k == 'setup': + var.pop(k) + if k == key: + yield v + if isinstance(v, dict): + for result in self._omit_keywords(key, v): + yield result + elif isinstance(v, list): + for d in v: + for result in self._omit_keywords(key, d): + yield result + + + def __init__(self, data, include_keywords=True, flattened_keywords=None): + """ + :param data: JSON to build + :class:`~.executionresult.Result` objects from. + :param include_keywords: Boolean controlling whether to include + keyword information in the result or not. Keywords are + not needed when generating only report. + :param flatten_keywords: List of patterns controlling what keywords to + flatten. See the documentation of ``--flattenkeywords`` option for + more details. + """ + self._data = data + self._include_keywords = include_keywords + self._flattened_keywords = flattened_keywords + + def build(self, result): + json_handler = JsonElementHandler(result) + json_handler.parse(self._data) + if not self._include_keywords: + result.suite.visit(RemoveKeywords()) + return result + + +class XmlExecutionResultBuilder(object): """Builds :class:`~.executionresult.Result` objects based on output files. Instead of using this builder directly, it is recommended to use the diff --git a/src/robot/run.py b/src/robot/run.py old mode 100755 new mode 100644 index 041e8c1e887..8a7f1598b95 --- a/src/robot/run.py +++ b/src/robot/run.py @@ -179,6 +179,9 @@ similarly as --log. Default: report.html -x --xunit file xUnit compatible result file. Not created unless this option is specified. + -j --json Use the JSON output format. The default file name is + "output.json". Alternatively the output filename can + end with ".json". --xunitskipnoncritical Deprecated since RF 4.0 and has no effect anymore. -b --debugfile file Debug file written during execution. Not created unless this option is specified. diff --git a/src/robot/testdoc.py b/src/robot/testdoc.py old mode 100755 new mode 100644 diff --git a/src/robot/tidy.py b/src/robot/tidy.py old mode 100755 new mode 100644 diff --git a/utest/output/test_logger.py b/utest/output/test_logger.py index 60384c46ca5..7055d32f801 100644 --- a/utest/output/test_logger.py +++ b/utest/output/test_logger.py @@ -189,7 +189,7 @@ def test_start_and_end_loggers_and_iter(self): listener = LoggerMock() lib_listener = LoggerMock() other = LoggerMock() - logger.register_xml_logger(xml) + logger.register_output_logger(xml) logger.register_listeners(listener, lib_listener) logger.register_logger(other) assert_equal([proxy.logger for proxy in logger.start_loggers], diff --git a/utest/reporting/test_reporting.py b/utest/reporting/test_reporting.py index 70b6578fa1a..554c58bf651 100644 --- a/utest/reporting/test_reporting.py +++ b/utest/reporting/test_reporting.py @@ -24,6 +24,11 @@ def test_only_output(self): self._write_results(output=output) self._verify_output(output.value) + def test_only_json(self): + output = ClosableOutput('output.json') + self._write_results(json=output) + self._verify_output(output.value) + def test_only_xunit(self): xunit = ClosableOutput('xunit.xml') self._write_results(xunit=xunit) @@ -51,11 +56,13 @@ def test_generate_all(self): xunit = ClosableOutput('x.xml') log = ClosableOutput('l.html') report = ClosableOutput('r.html') - self._write_results(output=output, xunit=xunit, log=log, report=report) + json = ClosableOutput('output.json') + self._write_results(output=output, xunit=xunit, log=log, report=report, json=json) self._verify_output(output.value) self._verify_xunit(xunit.value) self._verify_log(log.value) self._verify_report(report.value) + self._verify_output(json.value) def test_js_generation_does_not_prune_given_result(self): result = self._get_execution_result() @@ -133,6 +140,7 @@ class StubSettings(object): statistics_config = {} xunit_skip_noncritical = False expand_keywords = None + json = False def __init__(self, **settings): self.__dict__.update(settings) diff --git a/utest/run.py b/utest/run.py old mode 100755 new mode 100644 diff --git a/utest/run_jasmine.py b/utest/run_jasmine.py old mode 100755 new mode 100644 diff --git a/utest/webcontent/spec/data/create_jsdata_for_specs.py b/utest/webcontent/spec/data/create_jsdata_for_specs.py old mode 100755 new mode 100644