diff --git a/test_xenon.py b/test_xenon.py index 58371ee..bbc4456 100644 --- a/test_xenon.py +++ b/test_xenon.py @@ -3,7 +3,11 @@ import os import sys import unittest +import unittest.mock import collections +import tempfile +import sys +import argparse if sys.version_info[:2] >= (3, 10): import collections.abc @@ -12,6 +16,7 @@ import httpretty from paramunittest import parametrized +import xenon from xenon import core, api, main @@ -184,5 +189,305 @@ def test_api(self): 'message': 'Resource creation started'}) +class TestCustomConfigParserGetStr(unittest.TestCase): + '''Test class for class CustomConfigParser - getstr method.''' + + @staticmethod + def get_configuration(text): + '''Get CustomConfigParser object with loaded text.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write(text) + + configuration = xenon.CustomConfigParser() + configuration.read(pyproject_path) + + return configuration + + def test_missing_section_case(self): + '''Test missing section case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"]\n' + 'max_average = "A"\n' + 'max_average_num = 1.2\n') + + self.assertEqual( + configuration.getstr(xenon.PYPROJECT_SECTION, "maxaverage", "None"), "None") + + def test_with_trim_value_case(self): + '''Test with trim value case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"]\n' + 'max_average = "A"\n' + 'max_modules = \'B\'\n' + 'max_average_num = 1.2\n') + + self.assertEqual( + configuration.getstr(xenon.PYPROJECT_SECTION, "max_average", "None"), "A") + + self.assertEqual( + configuration.getstr(xenon.PYPROJECT_SECTION, "max_modules", "None"), "B") + + def test_without_trim_value_case(self): + '''Test without trim value case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"]\n' + 'max_average = A\n' + 'max_average-num = 1.2\n') + + self.assertEqual( + configuration.getstr(xenon.PYPROJECT_SECTION, "max_average", "None"), "A") + + +class TestCustomConfigParserGetListStr(unittest.TestCase): + '''Test class for class CustomConfigParser - getliststr method.''' + + @staticmethod + def get_configuration(text): + '''Get CustomConfigParser object with loaded text.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write(text) + + configuration = xenon.CustomConfigParser() + configuration.read(pyproject_path) + + return configuration + + def test_missing_section_case(self): + '''Test missing section case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"]\n' + 'max_average = "A"\n' + 'max_average-num = 1.2\n') + + self.assertEqual( + configuration.getliststr(xenon.PYPROJECT_SECTION, "maxaverage", "None"), "None") + + def test_parse_error_case(self): + '''Test parse error case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"\n' + 'max_average = "A"\n' + 'max_average-num = 1.2\n') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "path", configuration.getliststr, + xenon.PYPROJECT_SECTION, "path") + + def test_single_value_case(self): + '''Test single value case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = "path_1"\n' + 'max_average = "A"\n' + 'max_average-num = 1.2\n') + + self.assertListEqual( + configuration.getliststr(xenon.PYPROJECT_SECTION, "path", None), ["path_1"]) + + def test_invalid_format_case(self): + '''Test invalid format case''' + # Not a list case + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = {"path_1": "path_2"}\n' + 'max_average = "A"\n' + 'max_average-num = 1.2\n') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "path", configuration.getliststr, + xenon.PYPROJECT_SECTION, "path", None) + + # Not a list of str case + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", true]\n' + 'max_average = "A"\n' + 'max_average-num = 1.2\n') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "path", configuration.getliststr, + xenon.PYPROJECT_SECTION, "path", None) + + def test_multiple_values_case(self): + '''Test multiple values case.''' + configuration = self.get_configuration( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"]\n' + 'max_average = "A"\n' + 'max_average-num = 1.2\n') + + self.assertListEqual( + configuration.getliststr(xenon.PYPROJECT_SECTION, "path", None), + ["path_1", "path_2", "path_3"]) + + +class TestParsePyproject(unittest.TestCase): + '''Test class for function parse_pyproject.''' + + def test_parse_error_case(self): + '''Parse error case.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write('parameter = value') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "Unable", xenon.parse_pyproject, pyproject_path) + + def test_duplicate_parameteres_case(self): + '''Test duplicate parameters case''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write( + '[tool.xenon]\n' + 'max_average_num = value_1\n' + 'max_average_num = value_2') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "duplicate parameters", xenon.parse_pyproject, pyproject_path) + + def test_missing_file_case(self): + '''Test missing file path case.''' + with tempfile.TemporaryDirectory() as tmp_dir: + self.assertDictEqual(xenon.parse_pyproject(tmp_dir), {}) + + def test_invalid_max_average_num_type_case(self): + '''Test invalid max-average-num parameter type case''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write('[tool.xenon]\nmax_average_num = value') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "max_average_num", xenon.parse_pyproject, pyproject_path) + + def test_invalid_no_assert_type_case(self): + '''Test invalid no_assert parameter type case''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write('[tool.xenon]\nno_assert = next') + + self.assertRaisesRegex( + xenon.PyProjectParseError, "no_assert", xenon.parse_pyproject, pyproject_path) + + def test_all_parameters_case(self): + '''Test all parameters case.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write( + '[tool.xenon]\n' + 'path = ["path_1", "path_2", "path_3"]\n' + 'max_average = "A"\n' + 'max_average_num = 1.2\n' + 'max_modules = "B"\n' + 'max_absolute = "C"\n' + 'exclude = ["path_4", "path_5"]\n' + 'ignore = ["path_6", "path_7"]\n' + 'no_assert = true') + + result = xenon.parse_pyproject(pyproject_path) + + self.assertDictEqual( + xenon.parse_pyproject(pyproject_path), { + "path": ["path_1", "path_2", "path_3"], + "average": 'A', + "averagenum": 1.2, + "modules": 'B', + "absolute": 'C', + "url": None, + "config": None, + "exclude": 'path_4,path_5', + "ignore": 'path_6,path_7', + "no_assert": True}) + + +class TestGetParserDefaultsAndDeleteDefaults(unittest.TestCase): + '''Test class for function get_parser_defaults_and_delete_defaults.''' + + def test_valid_case(self): + '''Test valid case''' + parser = argparse.ArgumentParser(add_help=False) + + parser.add_argument("-t", "--test", default="test") + parser.add_argument("-v", "--values", default="values", dest="val") + parser.add_argument("-w", "--without") + + args_defualt = xenon.get_parser_defaults_and_delete_defaults(parser) + + self.assertEqual(args_defualt, {"test": "test", "val": "values"}) + + sys.argv = ["file.py"] + + parser.parse_args() + + self.assertEqual( + vars(parser.parse_args()), {"test": None, "val": None, "without": None}) + + +class TestParseArgs(unittest.TestCase): + '''Test class for function parse_args.''' + + def test_cmd_pyproject_case(self): + '''Test parameter set from cmd and pyproject case.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write( + '[tool.xenon]\n' + 'max_average = "A"\n') + + sys.argv = ["file", "-a", "B"] + + args = xenon.parse_args(pyproject_path) + self.assertEqual(args.average, "B") + + def test_cmd_default_value_case(self): + '''Test parameter not set from cmd (with default value) and pyproject case.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write( + '[tool.xenon]\n' + 'config_file = config_file_path\n') + + sys.argv = ["file"] + + args = xenon.parse_args(pyproject_path) + self.assertEqual(args.config, "config_file_path") + + def test_cmd_non_default_value_case(self): + '''Test parameter set from cmd (with default value) and pyproject case.''' + with tempfile.TemporaryDirectory() as tmp_dir: + pyproject_path = tmp_dir + '/pyproject.toml' + with open(pyproject_path, "w") as toml_file: + toml_file.write( + '[tool.xenon]\n' + 'config_file = config_file_path\n') + + # Set different value in cmd then default + sys.argv = ["file", "--config-file", "cfg_file_path"] + + args = xenon.parse_args(pyproject_path) + self.assertEqual(args.config, "cfg_file_path") + + # Set same value in cmd as default + sys.argv = ["file", "--config-file", ".xenon.yml"] + + args = xenon.parse_args(pyproject_path) + self.assertEqual(args.config, ".xenon.yml") + + if __name__ == '__main__': unittest.main() diff --git a/xenon/__init__.py b/xenon/__init__.py index a33631c..b7d3f21 100644 --- a/xenon/__init__.py +++ b/xenon/__init__.py @@ -7,20 +7,129 @@ import os import sys import logging +import json +import argparse -def parse_args(): - '''Parse arguments from the command line and read the config file (for the - REPO_TOKEN value). - ''' - import yaml - import argparse +# Import ConfigParser based on python version +if sys.version_info[0] == 2: + import ConfigParser as configparser +else: + import configparser + +PYPROJECT_SECTION = "tool.xenon" + + +class PyProjectParseError(Exception): + '''Exception for pyproject.toml parser.''' + def __init__(self, msg): + super(PyProjectParseError, self).__init__(msg) + + +class CustomConfigParser(configparser.ConfigParser): + '''Custom ConfigParser.''' + + def getstr(self, section, option, fallback=object()): + '''Get required option which is strings''' + values = self.get(section, option, fallback=fallback) + if values == fallback: + return fallback + + # Trim " or ' + if (values[0] == '"' and values[-1] == '"') or (values[0] == '\'' and values[-1] == '\''): + return values[1:-1] + + return values + + def getliststr(self, section, option, fallback=object()): + '''Get required option which is list of strings.''' + values = self.get(section, option, fallback=fallback) + if values == fallback: + return fallback + + # Conver string to python object + try: + values = json.loads(values) + except json.decoder.JSONDecodeError: + raise PyProjectParseError("Invalid format of parameter %s" % option) + + # Single parameter + if isinstance(values, str): + return [values] + + # Check format - list + if not isinstance(values, list): + raise PyProjectParseError("Invalid format of parameter %s" % option) + + # Check items + for value in values: + if not isinstance(value, str): + raise PyProjectParseError("Invalid format of parameter %s" % option) + + return values + + +def parse_pyproject(file_path): + '''Parse pyproject.toml file.''' + pyproject_parameters = {} + + configuration = CustomConfigParser() + + # Invalid format - missing any section [] + try: + loaded_files = configuration.read(file_path) + except configparser.MissingSectionHeaderError: + raise PyProjectParseError("Unable to parse %s" %file_path) + except configparser.DuplicateOptionError: + raise PyProjectParseError("%s contain duplicate parameters" %file_path) + + # Pyproject.toml does not exists + if not loaded_files: + return pyproject_parameters + + # Parse single string values + for parameter in (("max_average", "average"), + ("max_modules", "modules"), + ("max_absolute", "absolute"), + ("url", "url"), + ("config_file", "config")): + pyproject_parameters[parameter[1]] = configuration.getstr( + PYPROJECT_SECTION, parameter[0], fallback=None) + + # Parse list of string to str + for parameter in (("exclude", "exclude"), ("ignore", "ignore")): + values = configuration.getliststr(PYPROJECT_SECTION, parameter[0], None) + if values: + pyproject_parameters[parameter[1]] = ",".join(values) + else: + pyproject_parameters[parameter[1]] = None + + # Parse list of string as list + pyproject_parameters["path"] = configuration.getliststr( + PYPROJECT_SECTION, "path", fallback=None) + + try: + pyproject_parameters["averagenum"] = configuration.getfloat( + PYPROJECT_SECTION, "max_average_num", fallback=None) + except ValueError: + raise PyProjectParseError("Invalid format of parameter max_average_num") + + try: + pyproject_parameters["no_assert"] = configuration.getboolean( + PYPROJECT_SECTION, "no_assert", fallback=None) + except ValueError: + raise PyProjectParseError("Invalid format of parameter no_assert") + + return pyproject_parameters + +def get_parser(): + '''Get parser.''' parser = argparse.ArgumentParser() parser.add_argument('-v', '--version', action='version', version=__version__) - parser.add_argument('path', help='Directory containing source files to ' - 'analyze, or multiple file paths', nargs='+') + parser.add_argument('-p', '--path', help='Directory containing source files to ' + 'analyze, or multiple file paths', nargs='+', default='*.py') parser.add_argument('-a', '--max-average', dest='average', metavar='', help='Letter grade threshold for the average complexity') parser.add_argument('--max-average-num', dest='averagenum', type=float, @@ -46,13 +155,52 @@ def parse_args(): default='.xenon.yml', help='Xenon config file ' '(default: %(default)s)') - args = parser.parse_args() - # normalize the rank - for attr in ('absolute', 'modules', 'average'): - val = getattr(args, attr, None) - if val is None: + return parser + + +def get_parser_defaults_and_delete_defaults(parser): + '''Get defaults of parser and delete them in parser.''' + default_values = {} + + for argument in parser._actions: + if argument.default is not None: + default_values[argument.dest] = argument.default + argument.default = None + + return default_values + + +def parse_args(pyproject_file): + '''Parse arguments from the command line and read the config file (for the + REPO_TOKEN value). + ''' + import yaml + + args = argparse.Namespace() + + parser = get_parser() + args_default = get_parser_defaults_and_delete_defaults(parser) + args_cmd = parser.parse_args() + + # Include default values + for arg_name, arg_value in args_default.items(): + setattr(args, arg_name, arg_value) + + # Include args from pyproject.toml file + pyproject_args = parse_pyproject(pyproject_file) + for arg_name, arg_value in pyproject_args.items(): + # Argument not set in pyproject.toml + if not arg_value: continue - setattr(args, attr, val.upper()) + + # Set only in pyproject.toml + setattr(args, arg_name, arg_value) + + # Include args from cmd + for arg_name, arg_value in vars(args_cmd).items(): + if arg_value is not None: + setattr(args, arg_name, arg_value) + try: with open(args.config, 'r') as f: yml = yaml.safe_load(f) @@ -63,6 +211,14 @@ def parse_args(): os.environ.get('BARIUM_REPO_TOKEN', '')) args.service_name = yml.get('service_name', 'travis-ci') args.service_job_id = os.environ.get('TRAVIS_JOB_ID', '') + + # normalize the rank + for attr in ('absolute', 'modules', 'average'): + val = getattr(args, attr, None) + if val is None: + continue + setattr(args, attr, val.upper()) + return args @@ -74,7 +230,7 @@ def main(args=None): from xenon.core import analyze from xenon.repository import gitrepo - args = args or parse_args() + args = args or parse_args("pyproject.toml") logging.basicConfig(level=logging.INFO) logger = logging.getLogger('xenon') if args.url and len(args.path) > 1: