diff --git a/fmf/base.py b/fmf/base.py index b79041d5..66da11ea 100644 --- a/fmf/base.py +++ b/fmf/base.py @@ -17,15 +17,10 @@ import fmf.context import fmf.utils as utils -from fmf.utils import dict_to_yaml, log - -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -# Constants -# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -SUFFIX = ".fmf" -MAIN = "main" + SUFFIX -IGNORED_DIRECTORIES = ['/dev', '/proc', '/sys'] +from fmf.constants import (CONFIG_FILE_NAME, CONFIG_PLUGIN, + IGNORED_DIRECTORIES, MAIN, SUFFIX) +from fmf.plugin_loader import get_plugin_for_file, get_suffixes +from fmf.utils import FileSorting, dict_to_yaml, log # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # YAML @@ -96,10 +91,11 @@ def __init__(self, data, name=None, parent=None): self.original_data = dict() self._commit = None self._raw_data = dict() + self._plugin = None + self._config = dict() # Track whether the data dictionary has been updated # (needed to prevent removing nodes with an empty dict). self._updated = False - # Special handling for top parent if self.parent is None: self.name = "/" @@ -109,6 +105,7 @@ def __init__(self, data, name=None, parent=None): # Handle child node creation else: self.root = self.parent.root + self._config = self.parent._config self.name = os.path.join(self.parent.name, name) # Update data from a dictionary (handle empty nodes) @@ -180,6 +177,11 @@ def _initialize(self, path): "Unable to detect format version: {0}".format(error)) except ValueError: raise utils.FormatError("Invalid version format") + # try to read fmf config + config_file = os.path.join(self.root, ".fmf", CONFIG_FILE_NAME) + if os.path.exists(config_file): + with open(config_file) as fd: + self._config = yaml.safe_load(fd) def _merge_plus(self, data, key, value): """ Handle extending attributes using the '+' suffix """ @@ -418,7 +420,7 @@ def get(self, name=None, default=None): return default return data - def child(self, name, data, source=None): + def child(self, name, data, source=None, plugin=None): """ Create or update child with given data """ try: # Update data from a dictionary (handle empty nodes) @@ -433,6 +435,7 @@ def child(self, name, data, source=None): if source is not None: self.children[name].sources.append(source) self.children[name]._raw_data = copy.deepcopy(data) + self.children[name]._plugin = plugin def grow(self, path): """ @@ -454,25 +457,35 @@ def grow(self, path): except StopIteration: log.debug("Skipping '{0}' (not accessible).".format(path)) return - # Investigate main.fmf as the first file (for correct inheritance) - filenames = sorted( - [filename for filename in filenames if filename.endswith(SUFFIX)]) - try: - filenames.insert(0, filenames.pop(filenames.index(MAIN))) - except ValueError: - pass + + filenames_sorted = sorted([FileSorting(filename) for filename in filenames if any( + filter(filename.endswith, get_suffixes(*self._config.get(CONFIG_PLUGIN, []))))]) # Check every metadata file and load data (ignore hidden) - for filename in filenames: + for filename in [filename.value for filename in filenames_sorted]: if filename.startswith("."): continue fullpath = os.path.abspath(os.path.join(dirpath, filename)) log.info("Checking file {0}".format(fullpath)) - try: - with open(fullpath, encoding='utf-8') as datafile: - data = yaml.load(datafile, Loader=YamlLoader) - except yaml.error.YAMLError as error: - raise(utils.FileError("Failed to parse '{0}'.\n{1}".format( - fullpath, error))) + if fullpath.endswith(SUFFIX): + plugin = None + try: + with open(fullpath, encoding='utf-8') as datafile: + data = yaml.load(datafile, Loader=YamlLoader) + except yaml.error.YAMLError as error: + raise ( + utils.FileError( + "Failed to parse '{0}'.\n{1}".format( + fullpath, error))) + else: + data = None + plugin = get_plugin_for_file( + fullpath, *self._config.get(CONFIG_PLUGIN, [])) + log.debug("Used plugin {}".format(plugin)) + if plugin: + data = plugin().read(fullpath) + # ignore results of output if there is None + if data is None: + continue log.data(pretty(data)) # Handle main.fmf as data for self if filename == MAIN: @@ -481,7 +494,11 @@ def grow(self, path): self.update(data) # Handle other *.fmf files as children else: - self.child(os.path.splitext(filename)[0], data, fullpath) + self.child( + os.path.splitext(filename)[0], + data, + fullpath, + plugin=plugin) # Explore every child directory (ignore hidden dirs and subtrees) for dirname in sorted(dirnames): if dirname.startswith("."): @@ -673,7 +690,7 @@ def _locate_raw_data(self): node_data = node_data[key] # The full raw data were read from the last source - return node_data, full_data, node.sources[-1] + return node_data, full_data, node.sources[-1], hierarchy, node._plugin def __enter__(self): """ @@ -698,13 +715,31 @@ def __enter__(self): export to yaml does not preserve this information. The feature is experimental and can be later modified, use at your own risk. """ - return self._locate_raw_data()[0] + item = self._locate_raw_data()[0] + self._raw_data_before_modification = copy.deepcopy(item) + return item def __exit__(self, exc_type, exc_val, exc_tb): """ Experimental: Store modified metadata to disk """ - _, full_data, source = self._locate_raw_data() - with open(source, "w", encoding='utf-8') as file: - file.write(dict_to_yaml(full_data)) + node_data, full_data, source, hierarchy, plugin = self._locate_raw_data() + # find differences for plugins, to be able to work effectively + append = dict() + modified = dict() + for k, v in node_data.items(): + if k not in self._raw_data_before_modification: + append[k] = v + elif self._raw_data_before_modification[k] != v: + modified[k] = v + deleted = list() + for k in self._raw_data_before_modification: + if k not in node_data: + deleted.append(k) + + if plugin is None: + with open(source, "w", encoding='utf-8') as file: + file.write(dict_to_yaml(full_data)) + else: + plugin().write(source, hierarchy, node_data, append, modified, deleted) def __getitem__(self, key): """ diff --git a/fmf/constants.py b/fmf/constants.py new file mode 100644 index 00000000..d6528b72 --- /dev/null +++ b/fmf/constants.py @@ -0,0 +1,11 @@ +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Constants +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +SUFFIX = ".fmf" +MAIN = "main" + SUFFIX +IGNORED_DIRECTORIES = ['/dev', '/proc', '/sys'] +# comma separated list for plugin env var +PLUGIN_ENV = "PLUGINS" +CONFIG_FILE_NAME = "config" +CONFIG_PLUGIN = "plugins" diff --git a/fmf/plugin_loader.py b/fmf/plugin_loader.py new file mode 100644 index 00000000..c42b64d8 --- /dev/null +++ b/fmf/plugin_loader.py @@ -0,0 +1,105 @@ +import importlib +import inspect +import os +import re +from functools import lru_cache + +import yaml + +from fmf.constants import PLUGIN_ENV, SUFFIX +from fmf.utils import log + + +class Plugin: + """ + Main abstact class for FMF plugins + """ + # you have to define extension list as class attribute e.g. [".py"] + extensions = list() + file_patters = list() + + def read(self, filename): + """ + return python dictionary representation of metadata inside file (FMF structure) + """ + raise NotImplementedError("Define own impementation") + + @staticmethod + def __define_undefined(hierarchy, modified, append): + output = dict() + current = output + for key in hierarchy: + if key not in current or current[key] is None: + current[key] = dict() + current = current[key] + for k, v in modified.items(): + current[k] = v + for k, v in append.items(): + current[k] = v + return output + + def write( + self, filename, hierarchy, data, append_dict, modified_dict, + deleted_items): + """ + Write data in dictionary representation back to file, if not defined, create new fmf file with same name. + When created, nodes will not use plugin method anyway + """ + path = os.path.dirname(filename) + basename = os.path.basename(filename) + current_extension = list( + filter( + lambda x: basename.endswith(x), + self.extensions))[0] + without_extension = basename[0:-len(list(current_extension))] + fmf_file = os.path.join(path, without_extension + ".fmf") + with open(fmf_file, "w") as fd: + yaml.safe_dump( + self.__define_undefined( + hierarchy, + modified_dict, + append_dict), + stream=fd) + + +@lru_cache(maxsize=None) +def enabled_plugins(*plugins): + plugins = os.getenv(PLUGIN_ENV).split( + ",") if os.getenv(PLUGIN_ENV) else plugins + plugin_list = list() + for item in plugins: + if os.path.exists(item): + loader = importlib.machinery.SourceFileLoader( + os.path.basename(item), item) + module = importlib.util.module_from_spec( + importlib.util.spec_from_loader(loader.name, loader) + ) + loader.exec_module(module) + else: + module = importlib.import_module(item) + for name, plugin in inspect.getmembers(module): + if inspect.isclass(plugin) and plugin != Plugin and issubclass( + plugin, Plugin): + plugin_list.append(plugin) + log.info("Loaded plugin {}".format(plugin)) + return plugin_list + + +def get_suffixes(*plugins): + output = [SUFFIX] + for item in enabled_plugins(*plugins): + output += item.extensions + return output + + +def get_plugin_for_file(filename, *plugins): + extension = "." + filename.rsplit(".", 1)[1] + for item in enabled_plugins(*plugins): + if extension in item.extensions and any( + filter( + lambda x: re.search( + x, + filename), + item.file_patters)): + log.debug("File {} parsed by by plugin {}".format(filename, item)) + return item diff --git a/fmf/plugins/__init__.py b/fmf/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fmf/plugins/bash.py b/fmf/plugins/bash.py new file mode 100644 index 00000000..294742da --- /dev/null +++ b/fmf/plugins/bash.py @@ -0,0 +1,27 @@ +import os +import re + +from fmf.plugin_loader import Plugin +from fmf.utils import log + + +class Bash(Plugin): + extensions = [".sh"] + file_patters = ["test.*"] + + @staticmethod + def update_data(filename, pattern="^#.*:FMF:"): + out = dict(test="./" + os.path.basename(filename)) + with open(filename) as fd: + for line in fd.readlines(): + if re.match(pattern, line): + item = re.match( + r"{}\s*(.*)".format(pattern), + line).groups()[0] + identifier, value = item.split(":", 1) + out[identifier] = value.lstrip(" ") + return out + + def read(self, file_name): + log.info("Processing Item: {}".format(file_name)) + return self.update_data(file_name) diff --git a/fmf/plugins/pytest/__init__.py b/fmf/plugins/pytest/__init__.py new file mode 100644 index 00000000..0418e8b4 --- /dev/null +++ b/fmf/plugins/pytest/__init__.py @@ -0,0 +1,2 @@ +from fmf.plugins.pytest.plugin import Pytest +from fmf.plugins.pytest.tmt_semantic import TMT diff --git a/fmf/plugins/pytest/constants.py b/fmf/plugins/pytest/constants.py new file mode 100644 index 00000000..f32d6f40 --- /dev/null +++ b/fmf/plugins/pytest/constants.py @@ -0,0 +1,13 @@ +CONFIG_POSTPROCESSING_TEST = "test_postprocessing" +PYTEST_DEFAULT_CONF = { + CONFIG_POSTPROCESSING_TEST: { + "test": """ +cls_str = ("::" + str(cls.name)) if cls.name else "" +escaped = shlex.quote(f"{filename}{cls_str}::{test.name}") +f"python3 -m pytest -m '' -v {escaped}" """ + } +} +CONFIG_MERGE_PLUS = "merge_plus" +CONFIG_MERGE_MINUS = "merge_minus" +CONFIG_ADDITIONAL_KEY = "additional_keys" +CONFIG_POSTPROCESSING_TEST = "test_postprocessing" diff --git a/fmf/plugins/pytest/plugin.py b/fmf/plugins/pytest/plugin.py new file mode 100644 index 00000000..40def704 --- /dev/null +++ b/fmf/plugins/pytest/plugin.py @@ -0,0 +1,310 @@ +import ast +import importlib +import inspect +import os +import re +import shlex +from multiprocessing import Process, Queue + +import pytest +import yaml + +from fmf.plugin_loader import Plugin +from fmf.plugins.pytest.constants import (CONFIG_ADDITIONAL_KEY, + CONFIG_MERGE_MINUS, + CONFIG_MERGE_PLUS, + CONFIG_POSTPROCESSING_TEST, + PYTEST_DEFAULT_CONF) +from fmf.plugins.pytest.tmt_semantic import (DESCRIPTION_KEY, + FMF_POSTFIX_MARKS, SUMMARY_KEY, + TMT, TMT_ATTRIBUTES, + fmf_prefixed_name) +from fmf.utils import GeneralError, log + +_ = shlex + + +class _Test: + def __init__(self, test): + self.test = test + if hasattr(test, "_testMethodName"): + self.name = test._testMethodName + self.method = getattr(test.__class__, test._testMethodName) + else: + self.name = test.function.__name__ + self.method = test.function + + +class _TestCls: + def __init__(self, test_class, filename): + self.file = filename + self.cls = test_class + self.name = test_class.__name__ if test_class is not None else None + self.tests = [] + + +class ItemsCollector: + # current solution based on + # https://github.com/pytest-dev/pytest/discussions/8554 + def pytest_collection_modifyitems(self, items): + self.items = items[:] + + +def default_key(parent_dict, key, empty_obj): + if key not in parent_dict: + output = empty_obj + parent_dict[key] = output + return output + return parent_dict[key] + + +def __get_fmf_attr_name(method, attribute): + for current_attr in [fmf_prefixed_name(attribute + x) + for x in FMF_POSTFIX_MARKS]: + if hasattr(method, current_attr): + return current_attr + return fmf_prefixed_name(attribute) + + +def __update_dict_key(method, key, fmf_key, dictionary, override_postfix=""): + """ + This function have to ensure that there is righ one of attribute type extension + and removes all others + """ + value = None + current_postfix = "" + # find if item is defined inside method + for attribute in dir(method): + stripped = attribute.rstrip("".join(FMF_POSTFIX_MARKS)) + if key == stripped: + value = getattr(method, attribute) + strip_len = len(stripped) + current_postfix = attribute[strip_len:] + # delete all keys in dictionary started with fmf_key + for item in dictionary.copy(): + stripped = item.rstrip("".join(FMF_POSTFIX_MARKS)) + if stripped == fmf_key: + dictionary.pop(item) + out_key = ( + fmf_key + override_postfix + if override_postfix else fmf_key + current_postfix) + if value is not None: + dictionary[out_key] = value + + +def multiline_eval(expr, context, type_ignores=None): + """Evaluate several lines of input, returning the result of the last line + https://stackoverflow.com/questions/12698028/why-is-pythons-eval-rejecting-this-multiline-string-and-how-can-i-fix-it + """ + tree = ast.parse(expr) + eval_expr = ast.Expression(tree.body[-1].value) + exec_expr = ast.Module(tree.body[:-1], type_ignores=type_ignores or []) + exec(compile(exec_expr, "file", "exec"), context) + return eval(compile(eval_expr, "file", "eval"), context) + + +def __post_processing(input_dict, config_dict, cls, test, filename): + if isinstance(config_dict, dict): + for k, v in config_dict.items(): + if isinstance(v, dict): + if k not in input_dict: + input_dict[k] = dict() + __post_processing(input_dict[k], v, cls, test, filename) + else: + input_dict[k] = multiline_eval(v, dict(locals(), **globals())) + + +def read_config(config_file): + if not os.path.exists(config_file): + raise GeneralError( + f"configuration files does not exists {config_file}") + log.info(f"Read config file: {config_file}") + with open(config_file) as fd: + return yaml.safe_load(fd) + + +def test_data_dict(test_dict, config, filename, cls, test, + merge_plus_list=None, merge_minus_list=None): + merge_plus_list = merge_plus_list or config.get(CONFIG_MERGE_PLUS, []) + merge_minus_list = merge_minus_list or config.get(CONFIG_MERGE_MINUS, []) + doc_str = (test.method.__doc__ or "").strip("\n") + # set summary attribute if not given by decorator + current_name = __get_fmf_attr_name(test.method, SUMMARY_KEY) + if not hasattr(test.method, current_name): + # try to use first line of docstring if given + if doc_str: + summary = doc_str.split("\n")[0].strip() + else: + summary = ( + (f"{os.path.basename(filename)} " if filename else "") + + (f"{cls.name} " if cls.name else "") + + test.name + ) + setattr(test.method, current_name, summary) + + # set description attribute by docstring if not given by decorator + current_name = __get_fmf_attr_name(test.method, DESCRIPTION_KEY) + if not hasattr(test.method, current_name): + # try to use first line of docstring if given + if doc_str: + description = doc_str + setattr(test.method, current_name, description) + # generic FMF attributes set by decorators + for key in TMT_ATTRIBUTES: + # Allow to override key storing with merging postfixes + override_postfix = "" + if key in merge_plus_list: + override_postfix = "+" + elif key in merge_minus_list: + override_postfix = "-" + __update_dict_key( + test.method, + fmf_prefixed_name(key), + key, + test_dict, + override_postfix, + ) + + # special config items + if CONFIG_ADDITIONAL_KEY in config: + for key, fmf_key in config[CONFIG_ADDITIONAL_KEY].items(): + __update_dict_key(test.method, key, fmf_key, test_dict) + if CONFIG_POSTPROCESSING_TEST in config: + __post_processing( + test_dict, config[CONFIG_POSTPROCESSING_TEST], cls, test, filename + ) + return test_dict + + +def define_undefined(input_dict, keys, config, relative_test_path, cls, test): + for item in keys: + item_id = f"/{item}" + default_key(input_dict, item_id, empty_obj={}) + input_dict = input_dict[item_id] + test_data_dict( + test_dict=input_dict, + config=config, + filename=relative_test_path, + cls=cls, + test=test, + ) + + +def collect(opts): + plugin_col = ItemsCollector() + pytest.main( + ["--collect-only", "-pno:terminal", "-m", ""] + opts, + plugins=[plugin_col]) + for item in plugin_col.items: + func = item.function + for marker in item.iter_markers(): + key = marker.name + args = marker.args + kwargs = marker.kwargs + + if key == "skip": + TMT.enabled(False)(func) + elif key == "skipif": + # add skipif as tag as well (possible to use adjust, but + # conditions are python code) + arg_string = "SKIP " + if args: + arg_string += " ".join(map(str, args)) + if "reason" in kwargs: + arg_string += " " + kwargs["reason"] + TMT.tag(arg_string)(func) + elif key == "parametrize": + # do nothing, parameters are already part of test name + pass + else: + # generic mark store as tag + TMT.tag(key)(func) + return plugin_col.items + + +class Pytest(Plugin): + extensions = [".py"] + file_patters = ["test.*"] + + @staticmethod + def update_data(store_dict, func, config): + keys = [] + filename = os.path.basename(func.fspath) + if func.cls: + cls = _TestCls(func.cls, filename) + keys.append(cls.name) + else: + cls = _TestCls(None, filename) + test = _Test(func) + # normalise test name to pytest identifier + test.name = re.search( + f".*({os.path.basename(func.function.__name__)}.*)", func.name + ).group(1) + # TODO: removed str_normalise(...) will see what happen + keys.append(test.name) + define_undefined(store_dict, keys, config, filename, cls, test) + return store_dict + + def read(self, file_name): + def call_collect(queue, file_name): + """ + have to call in separate process, to avoid problems with pytest multiple collectitons + when called twice on same data test list is empty because already imported + """ + out = dict() + + for item in collect([file_name]): + self.update_data(store_dict=out, func=item, + config=PYTEST_DEFAULT_CONF) + log.info("Processing Item: {}".format(item.function)) + queue.put(out) + + process_queue = Queue() + process = Process(target=call_collect, + args=(process_queue, file_name,)) + process.start() + out = process_queue.get() + process.join() + if out: + return out + return None + + @staticmethod + def import_test_module(filename): + loader = importlib.machinery.SourceFileLoader( + os.path.basename(filename), filename) + module = importlib.util.module_from_spec( + importlib.util.spec_from_loader(loader.name, loader) + ) + loader.exec_module(module) + return module + + def write( + self, filename, hierarchy, data, append_dict, modified_dict, + deleted_items): + module = self.import_test_module(filename) + where = module + for item in hierarchy: + where = getattr(where, item.lstrip("/")) + lines, start_line = inspect.getsourcelines(where) + spaces = re.match(r"(^\s*)", lines[0]).groups()[0] + # try to find if already defined + with open(filename, "r") as f: + contents = f.readlines() + for k in deleted_items: + for num, line in enumerate(lines): + if re.match(r"{}.*@TMT\.{}".format(spaces, k), line): + contents.pop(start_line + num - 1) + for k, v in modified_dict.items(): + for num, line in enumerate(lines): + if re.match(r"{}.*@TMT\.{}".format(spaces, k), line): + contents.pop(start_line + num - 1) + append_dict[k] = v + for k, v in append_dict.items(): + contents.insert(start_line, + """{}@TMT.{}({})\n""".format(spaces, + k, + repr(v)[1:-1] if isinstance(v, + list) else repr(v))) + with open(filename, "w") as f: + f.writelines(contents) diff --git a/fmf/plugins/pytest/tmt_semantic.py b/fmf/plugins/pytest/tmt_semantic.py new file mode 100644 index 00000000..5cbbd3ba --- /dev/null +++ b/fmf/plugins/pytest/tmt_semantic.py @@ -0,0 +1,273 @@ +import inspect +import shlex +import unittest + +from fmf.utils import GeneralError + +TEST_METHOD_PREFIX = "test" +FMF_ATTR_PREFIX = "__fmf_" +FMF_POSTFIX_MARKS = ("+", "-", "") +SUMMARY_KEY = "summary" +DESCRIPTION_KEY = "description" +ENVIRONMENT_KEY = "environment" +_ = shlex + +TMT_ATTRIBUTES = { + SUMMARY_KEY: str, + DESCRIPTION_KEY: str, + "order": int, + "adjust": ( + list, + dict, + ), + "tag": ( + list, + str, + ), + "link": (list, str, dict), + "duration": str, + "tier": str, + "component": ( + list, + str, + ), + "require": ( + list, + str, + dict, + ), + "test": (str,), + "framework": (str,), + ENVIRONMENT_KEY: ( + dict, + str, + ), + "path": (str,), + "enabled": (bool,), +} + + +def fmf_prefixed_name(name): + return FMF_ATTR_PREFIX + name + + +class __TMTMeta(type): + @staticmethod + def _set_fn(name, base_type=None): + if name not in TMT_ATTRIBUTES: + raise GeneralError( + "fmf decorator {} not found in {}".format( + name, TMT_ATTRIBUTES.keys())) + + def inner(*args, post_mark=""): + return generic_metadata_setter( + fmf_prefixed_name(name), + args, + base_type=base_type or TMT_ATTRIBUTES[name], + post_mark=post_mark, + ) + + return inner + + def __getattr__(cls, name): + return cls._set_fn(name) + + +class TMT(metaclass=__TMTMeta): + """ + This class implements class decorators for TMT semantics via dynamic class methods + see https://tmt.readthedocs.io/en/latest/spec/tests.html + """ + + @classmethod + def tag(cls, *args, post_mark=""): + """ + generic purpose test tags to be used (e.g. "slow", "fast", "security") + https://tmt.readthedocs.io/en/latest/spec/tests.html#tag + """ + return cls._set_fn("tag", base_type=TMT_ATTRIBUTES["tag"])( + *args, post_mark=post_mark + ) + + @classmethod + def link(cls, *args, post_mark=""): + """ + generic url links (default is verify) but could contain more see TMT doc + https://tmt.readthedocs.io/en/latest/spec/core.html#link + """ + return cls._set_fn("link", base_type=TMT_ATTRIBUTES["link"])( + *args, post_mark=post_mark + ) + + @classmethod + def bug(cls, *args, post_mark=""): + """ + link to relevant bugs what this test verifies. + It can be link to issue tracker or bugzilla + https://tmt.readthedocs.io/en/latest/spec/tests.html#link + """ + return cls.link( + *[{"verifies": arg} for arg in args], + post_mark=post_mark) + + @classmethod + def adjust( + cls, when, because=None, continue_execution=True, post_mark="", ** + kwargs): + """ + adjust testcase execution, see TMT specification + https://tmt.readthedocs.io/en/latest/spec/core.html#adjust + + if key value arguments are passed they are applied as update of the dictionary items + else disable test execution as default option + + e.g. + + @adjust("distro ~< centos-6", "The test is not intended for less than centos-6") + @adjust("component == bash", "modify component", component="shell") + + tricky example with passing merging variables as kwargs to code + because python does not allow to do parameter as X+="something" + use **dict syntax for parameter(s) + + @adjust("component == bash", "append env variable", **{"environment+": {"BASH":true}}) + """ + adjust_item = dict() + adjust_item["when"] = when + if because is not None: + adjust_item["because"] = because + if kwargs: + adjust_item.update(kwargs) + else: + adjust_item["enabled"] = False + if continue_execution is False: + adjust_item["continue"] = False + return cls._set_fn("adjust", base_type=TMT_ATTRIBUTES["adjust"])( + adjust_item, post_mark=post_mark + ) + + @classmethod + def environment(cls, post_mark="", **kwargs): + """ + environment testcase execution, see TMT specification + https://tmt.readthedocs.io/en/latest/spec/test.html#environment + + add environment keys + example: + @environment(PYTHONPATH=".", DATA_DIR="test_data") + """ + return cls._set_fn( + ENVIRONMENT_KEY, base_type=TMT_ATTRIBUTES[ENVIRONMENT_KEY])( + kwargs, post_mark=post_mark) + + +def is_test_function(member): + return inspect.isfunction(member) and member.__name__.startswith( + TEST_METHOD_PREFIX) + + +def __set_method_attribute(item, attribute, value, post_mark, base_type=None): + if post_mark not in FMF_POSTFIX_MARKS: + raise GeneralError( + "as postfix you can use + or - or let it empty (FMF merging)") + attr_postfixed = attribute + post_mark + for postfix in set(FMF_POSTFIX_MARKS) - {post_mark}: + if hasattr(item, attribute + postfix): + raise GeneralError( + "you are mixing various post_marks for {} ({} already exists)".format( + item, attribute + postfix)) + if base_type is None: + if isinstance(value, list) or isinstance(value, tuple): + base_type = (list,) + elif isinstance(value, dict): + base_type = dict + value = [value] + else: + value = [value] + + if isinstance(base_type, tuple) and base_type[0] in [tuple, list]: + if not hasattr(item, attr_postfixed): + setattr(item, attr_postfixed, list()) + # check expected object types for FMF attributes + for value_item in value: + if len(base_type) > 1 and not isinstance( + value_item, tuple(base_type[1:])): + raise GeneralError( + "type {} (value:{}) is not allowed, please use: {} ".format( + type(value_item), value_item, base_type[1:] + ) + ) + getattr(item, attr_postfixed).extend(list(value)) + return + + # use just first value in case you don't use list of tuple + if len(value) > 1: + raise GeneralError( + "It is not permitted for {} (type:{}) put multiple values ({})".format( + attribute, base_type, value)) + first_value = value[0] + if base_type and not isinstance(first_value, base_type): + raise GeneralError( + "type {} (value:{}) is not allowed, please use: {} ".format( + type(first_value), first_value, base_type + ) + ) + if base_type in [dict]: + if not hasattr(item, attr_postfixed): + setattr(item, attr_postfixed, dict()) + first_value.update(getattr(item, attr_postfixed)) + if hasattr(item, attr_postfixed) and base_type not in [dict]: + # if it is already defined (not list types or dict) exit + # class decorators are applied right after, does not make sense to rewrite more specific + # dict updating is reversed + return + setattr(item, attr_postfixed, first_value) + + +def set_obj_attribute( + testEntity, + attribute, + value, + raise_text=None, + base_class=unittest.TestCase, + base_type=None, + post_mark="", +): + if inspect.isclass(testEntity) and issubclass(testEntity, base_class): + for test_function in inspect.getmembers(testEntity, is_test_function): + __set_method_attribute( + test_function[1], + attribute, + value, + post_mark=post_mark, + base_type=base_type, + ) + elif is_test_function(testEntity): + __set_method_attribute( + testEntity, attribute, value, base_type=base_type, + post_mark=post_mark) + elif raise_text: + raise GeneralError(raise_text) + return testEntity + + +def generic_metadata_setter( + attribute, + value, + raise_text=None, + base_class=unittest.TestCase, + base_type=None, + post_mark="", +): + def inner(testEntity): + return set_obj_attribute( + testEntity, + attribute, + value, + raise_text, + base_class, + base_type=base_type, + post_mark=post_mark, + ) + + return inner diff --git a/fmf/utils.py b/fmf/utils.py index cca040ec..9ebc199c 100644 --- a/fmf/utils.py +++ b/fmf/utils.py @@ -13,7 +13,7 @@ import sys import time import warnings -from pprint import pformat as pretty +from functools import total_ordering import yaml from filelock import FileLock, Timeout @@ -25,6 +25,7 @@ from io import StringIO import fmf.base +from fmf.constants import MAIN # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Constants @@ -856,3 +857,44 @@ def representer(self, data): return self.represent_mapping( return output.getvalue().decode('utf-8') except AttributeError: return output.getvalue() + + +@total_ordering +class FileSorting: + def __init__(self, value): + self._value = value + + @property + def value(self): + return self._value + + @property + def splitted(self): + splitted = self._value.rsplit(".", 1) + basename = splitted[0] + suffix = splitted[1] if len(splitted) > 1 else '' + return basename, suffix + + @property + def basename(self): + return self.splitted[0] + + @property + def suffix(self): + return self.splitted[1] + + def __lt__(self, other): + # Investigate main.fmf as the first file (for correct inheritance) + if self.value == MAIN: + return True + # if there are same filenames and other endswith fmf, it has to be last + # one + elif self.basename == other.basename and other.suffix == "fmf": + return True + elif self.basename == other.basename and self.suffix == "fmf": + return False + else: + return self.value < other.value + + def __eq__(self, other): + return self.value == other.value diff --git a/setup.py b/setup.py index 3549f011..1aae1811 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ import re from io import open -from setuptools import setup +from setuptools import find_packages, setup # Parse version from the spec file with open('fmf.spec', encoding='utf-8') as specfile: @@ -15,7 +15,6 @@ __version__ = version __pkg__ = 'fmf' __pkgdir__ = {} -__pkgs__ = ['fmf'] __provides__ = ['fmf'] __desc__ = 'Flexible Metadata Format' __scripts__ = ['bin/fmf'] @@ -59,7 +58,7 @@ install_requires=__irequires__, name=__pkg__, package_dir=__pkgdir__, - packages=__pkgs__, + packages=find_packages(), provides=__provides__, scripts=__scripts__, version=__version__, diff --git a/tests/unit/data/config/.fmf/config b/tests/unit/data/config/.fmf/config new file mode 100644 index 00000000..785cb6e0 --- /dev/null +++ b/tests/unit/data/config/.fmf/config @@ -0,0 +1,2 @@ +plugins: + - fmf.plugins.pytest diff --git a/tests/unit/data/config/.fmf/version b/tests/unit/data/config/.fmf/version new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/unit/data/config/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/tests/unit/data/config/test_plugin_config.py b/tests/unit/data/config/test_plugin_config.py new file mode 100644 index 00000000..adabd767 --- /dev/null +++ b/tests/unit/data/config/test_plugin_config.py @@ -0,0 +1,12 @@ +import unittest + + +class TestCls(unittest.TestCase): + def test_1(self): + self.assertTrue(True) + + def test_2(self): + self.assertTrue(True) + + def test_3(self): + self.assertTrue(True) diff --git a/tests/unit/data/tests_plugin/.fmf/version b/tests/unit/data/tests_plugin/.fmf/version new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/tests/unit/data/tests_plugin/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/tests/unit/data/tests_plugin/main.fmf b/tests/unit/data/tests_plugin/main.fmf new file mode 100644 index 00000000..91600fc7 --- /dev/null +++ b/tests/unit/data/tests_plugin/main.fmf @@ -0,0 +1,5 @@ +author: Jan Scotka + +/pure_fmf: + test: ./runtest.sh + summary: Pure FMF test case diff --git a/tests/unit/data/tests_plugin/runtest.sh b/tests/unit/data/tests_plugin/runtest.sh new file mode 100644 index 00000000..493c2c44 --- /dev/null +++ b/tests/unit/data/tests_plugin/runtest.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# :FMF: summary: Basic test case: 1 +# :FMF: tag: tier1 + +true diff --git a/tests/unit/data/tests_plugin/test_basic.py b/tests/unit/data/tests_plugin/test_basic.py new file mode 100644 index 00000000..ea3593d9 --- /dev/null +++ b/tests/unit/data/tests_plugin/test_basic.py @@ -0,0 +1,32 @@ +import unittest + +import pytest + +from fmf.plugins.pytest import TMT + + +@TMT.tag("Tier1") +@TMT.tier("0") +@TMT.summary("This is basic testcase") +def test_pass(): + assert True + + +def test_fail(): + assert True + + +@TMT.summary("Some summary") +@pytest.mark.skip +def test_skip(): + assert True + + +@pytest.mark.parametrize("test_input", ["a", "b", "c"]) +def test_parametrize(test_input): + assert bool(test_input) + + +class TestCls(unittest.TestCase): + def test(self): + self.assertTrue(True) diff --git a/tests/unit/data/tests_plugin/test_rewrite.fmf b/tests/unit/data/tests_plugin/test_rewrite.fmf new file mode 100644 index 00000000..bff0f18d --- /dev/null +++ b/tests/unit/data/tests_plugin/test_rewrite.fmf @@ -0,0 +1,4 @@ +/test_pass: + added_fmf_file: added + summary: Rewrite + tag+: [tier2] diff --git a/tests/unit/data/tests_plugin/test_rewrite.py b/tests/unit/data/tests_plugin/test_rewrite.py new file mode 100644 index 00000000..b3413529 --- /dev/null +++ b/tests/unit/data/tests_plugin/test_rewrite.py @@ -0,0 +1,7 @@ +from fmf.plugins.pytest import TMT + + +@TMT.tag("Tier1") +@TMT.summary("Rewritten") +def test_pass(): + assert True diff --git a/tests/unit/pytest.ini b/tests/unit/pytest.ini index cafe5e5d..c8be441b 100644 --- a/tests/unit/pytest.ini +++ b/tests/unit/pytest.ini @@ -1,3 +1,4 @@ [pytest] markers = web: tests which need to access the web +norecursedirs = *unit/data* diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py new file mode 100644 index 00000000..51ae04f7 --- /dev/null +++ b/tests/unit/test_plugin.py @@ -0,0 +1,136 @@ +# coding: utf-8 + +from __future__ import absolute_import, unicode_literals + +import os +import tempfile +import unittest +from pathlib import Path +from shutil import copytree, rmtree + +from fmf.base import Tree +from fmf.constants import PLUGIN_ENV +from fmf.plugin_loader import enabled_plugins + +PATH = Path(__file__).parent +EXAMPLES = PATH / "data" +PLUGIN_PATH = PATH.parent.parent / "fmf" / "plugins" + + +class Base(unittest.TestCase): + def setUp(self): + self.test_path = EXAMPLES / "tests_plugin" + self.tempdir = tempfile.mktemp() + copytree(self.test_path, self.tempdir) + # ensure the cache is cleared, to ensure that plugis are not already + # stored + enabled_plugins.cache_clear() + + def tearDown(self): + enabled_plugins.cache_clear() + rmtree(self.tempdir) + os.environ.pop(PLUGIN_ENV) + + +class Pytest(Base): + """ Verify reading data done via plugins """ + + def setUp(self): + super().setUp() + os.environ[PLUGIN_ENV] = "fmf.plugins.pytest" + self.plugin_tree = Tree(self.tempdir) + + def test_basic(self): + item = self.plugin_tree.find("/test_basic/test_skip") + + self.assertFalse(item.data.get("enabled")) + self.assertIn("Jan", item.data["author"]) + self.assertIn( + "python3 -m pytest -m '' -v test_basic.py::test_skip", item.data + ["test"]) + + def test_modify(self): + item = self.plugin_tree.find("/test_basic/test_pass") + self.assertNotIn("duration", item.data) + self.assertIn("Tier1", item.data["tag"]) + self.assertNotIn("tier2", item.data["tag"]) + self.assertEqual("0", item.data["tier"]) + with item as data: + data["tag"].append("tier2") + data["duration"] = ("10m") + data.pop("tier") + + self.plugin_tree = Tree(self.tempdir) + item = self.plugin_tree.find("/test_basic/test_pass") + self.assertIn("duration", item.data) + self.assertEqual("10m", item.data["duration"]) + self.assertIn("Tier1", item.data["tag"]) + self.assertIn("tier2", item.data["tag"]) + self.assertIn("tier2", item.data["tag"]) + self.assertNotIn("tier", item.data) + + def test_rewrite(self): + item = self.plugin_tree.find("/test_rewrite/test_pass") + self.assertNotIn("duration", item.data) + self.assertIn("Tier1", item.data["tag"]) + self.assertIn("tier2", item.data["tag"]) + self.assertEqual("added", item.data["added_fmf_file"]) + self.assertEqual("Rewrite", item.data["summary"]) + + def test_rewrite_modify(self): + self.test_rewrite() + item = self.plugin_tree.find("/test_rewrite/test_pass") + with item as data: + data["tag+"] += ["tier3"] + data["extra_id"] = 1234 + + self.plugin_tree = Tree(self.tempdir) + item = self.plugin_tree.find("/test_rewrite/test_pass") + self.test_rewrite() + self.assertEqual(1234, item.data["extra_id"]) + self.assertIn("tier3", item.data["tag"]) + + +class Bash(Base): + """ Verify reading data done via plugins """ + + def setUp(self): + super().setUp() + os.environ[PLUGIN_ENV] = str(PLUGIN_PATH / "bash.py") + self.plugin_tree = Tree(self.tempdir) + + def test_read(self): + item = self.plugin_tree.find("/runtest") + self.assertIn("tier1", item.data["tag"]) + self.assertIn("./runtest.sh", item.data["test"]) + self.assertIn("Jan", item.data["author"]) + + def test_modify(self): + self.assertNotIn("runtest.fmf", os.listdir(self.tempdir)) + item = self.plugin_tree.find("/runtest") + self.assertIn("tier1", item.data["tag"]) + self.assertIn("./runtest.sh", item.data["test"]) + with item as data: + data["tier"] = 0 + data["duration"] = "10m" + self.plugin_tree = Tree(self.tempdir) + item = self.plugin_tree.find("/runtest") + self.assertIn("runtest.fmf", os.listdir(self.tempdir)) + self.assertEqual("10m", item.data["duration"]) + self.assertEqual(0, item.data["tier"]) + self.assertIn("tier1", item.data["tag"]) + self.assertIn("./runtest.sh", item.data["test"]) + + +class TestConf(unittest.TestCase): + def setUp(self): + self.test_path = EXAMPLES / "config" + enabled_plugins.cache_clear() + self.plugin_tree = Tree(self.test_path) + + def tearDown(self): + enabled_plugins.cache_clear() + + def test_basic(self): + item = self.plugin_tree.find("/test_plugin_config/TestCls") + self.assertEqual(len(item.children), 3)