Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/_balder/controllers/scenario_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ def __init__(self, related_cls, _priv_instantiate_key):
# describes if the current controller is for setups or for scenarios (has to be set in child controller)
self._related_type = Scenario

# holds covered-by configuration
self._covered_by = {}

# this helps to make this constructor only possible inside the controller object
if _priv_instantiate_key != ScenarioController.__priv_instantiate_key:
raise RuntimeError('it is not allowed to instantiate a controller manually -> use the static method '
Expand Down Expand Up @@ -127,6 +130,73 @@ def get_parametrization_for(
ordered_dict[cur_arg] = params[cur_arg]
return ordered_dict

def register_covered_by_for(self, meth: Union[str, None], covered_by: Union[Scenario, Callable, None]) -> None:
"""
This method registers a covered-by statement for this Scenario. If `meth` is provided, the statement is for the
specific test method of the scenario, otherwise it is for the whole setup. The item provided in `covered_by`
describes the test object that covers this scenario (method).

:param meth: if provided this attribute describes the test method that should be registered, otherwise the whole
scenario will be registered
:param covered_by: describes the test object that covers this scenario (method)
"""
if not (meth is None or isinstance(meth, str)):
raise TypeError('meth needs to be None or a string')
if meth is not None:
if not meth.startswith('test_'):
raise TypeError(
f"the use of the `@covered_by` decorator is only allowed for `Scenario` objects and test methods "
f"of `Scenario` objects - the method `{self.related_cls.__name__}.{meth}` does not start with "
f"`test_` and is not a valid test method")
if not hasattr(self.related_cls, meth):
raise ValueError(
f"the provided test method `{meth}` does not exist in scenario `{self.related_cls.__name__}`"
)

if meth not in self._covered_by.keys():
self._covered_by[meth] = []
if covered_by is None:
# reset it
# todo what if there are more than one decorator in one class
del self._covered_by[meth]
else:
self._covered_by[meth].append(covered_by)

def get_raw_covered_by_dict(self) -> Dict[Union[str, None], List[Union[Scenario, Callable]]]:
"""
:return: returns the internal covered-by dictionary
"""
return self._covered_by.copy()

def get_abs_covered_by_dict(self) -> Dict[Union[str, None], List[Union[Scenario, Callable]]]:
"""
This method resolves the covered-by statements over all inheritance levels. It automatically
cleans up every inheritance of the covered_by decorators for every parent class of this scenario.
"""
parent_classes = [p for p in self.related_cls.__bases__ if issubclass(p, Scenario) and p != Scenario]
if len(parent_classes) > 1:
raise MultiInheritanceError(
f'can not resolve classes for `{self.related_cls}` because there are more than one Scenario based '
f'parent classes'
)
# no more parent classes -> raw is absolute
if len(parent_classes) == 0:
return self.get_raw_covered_by_dict()
parent_controller = self.__class__.get_for(parent_classes[0])
self_raw_covered_by_dict = self.get_raw_covered_by_dict()

#: first fill result with data from parent controller
result = {
k if k is None else getattr(self.related_cls, k.__name__): v
for k, v in parent_controller.get_abs_covered_by_dict().items()
}
for cur_callable, cur_coveredby in self_raw_covered_by_dict.items():
if cur_callable in result.keys():
result[cur_callable].extend(cur_coveredby)
else:
result[cur_callable] = cur_coveredby
return result

def check_for_parameter_loop_in_dynamic_parametrization(self, cur_fn: Callable):
"""
This method checks for a parameter loop in all dynamic parametrization for a specific test method. If it detects
Expand Down
64 changes: 28 additions & 36 deletions src/_balder/decorator_covered_by.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
from typing import Type, Union

import inspect

from _balder.controllers import ScenarioController
from _balder.scenario import Scenario
from _balder.utils import get_class_that_defines_method


def covered_by(item: Union[Type[Scenario], callable, None]):
def covered_by(item: Union[Type[Scenario], str, callable, None]):
"""
This decorator defines that there exists another Scenario class or test method item that has a similar
implementation like the decorated :class:`Scenario` class or the decorated test method.
Expand All @@ -16,14 +17,15 @@ def covered_by(item: Union[Type[Scenario], callable, None]):

if item is None:
pass
elif callable(item) and inspect.isfunction(item) and item.__name__.startswith("test_") and \
issubclass(get_class_that_defines_method(item), Scenario):
elif isinstance(item, str) and item.startswith("test_"):
pass
elif isinstance(item, type) and issubclass(item, Scenario):
elif callable(item) and inspect.isfunction(item) and item.__name__.startswith("test_"):
pass
elif isinstance(item, type) and issubclass(item, Scenario):
raise NotImplementedError('The covered-by other scenario classes is not supported yet')
else:
raise TypeError("the given element for `item` must be a `Scenario` (or a subclass thereof) or a test method of "
"a scenario class (has to start with `test_`)")
raise TypeError("the given element for `item` must be a test method of a scenario class (has to start with "
"`test_`)")

class CoveredByDecorator:
"""decorator class for `@covered_by` decorator"""
Expand All @@ -36,46 +38,36 @@ def __init__(self, func):
raise TypeError(f"The decorator `@covered_by` may only be used for `Scenario` objects or for test "
f"methods of one `Scenario` object. This is not possible for the applied class "
f"`{func.__name__}`.")
if not hasattr(func, '_covered_by'):
func._covered_by = {}
if func not in func._covered_by.keys():
func._covered_by[func] = []
if item is None:
# reset it
func._covered_by[func] = []
elif item not in func._covered_by[func]:
func._covered_by[func].append(item)
elif inspect.isfunction(func):
raise NotImplementedError('The covered-by decoration of other scenario classes is not supported yet')
# scenario_controller = ScenarioController.get_for(func)
# register for the whole class
# scenario_controller.register_covered_by_for(meth=None, covered_by=item)
if inspect.isfunction(func):
# work will done in `__set_name__`
pass
else:
raise TypeError(f"The use of the `@covered_by` decorator is not allowed for the `{str(func)}` element. "
f"You should only use this decorator for `Scenario` elements or test method elements "
f"of a `Scenario` object")
f"You should only use this decorator for test method elements of a `Scenario` object")

def __set_name__(self, owner, name):
if issubclass(owner, Scenario):
if not inspect.isfunction(self.func):
raise TypeError("the use of the `@covered_by` decorator is only allowed for `Scenario` objects and "
"test methods of `Scenario` objects")
raise TypeError("the use of the `@covered_by` decorator is only allowed for test methods of "
"`Scenario` objects")
if not name.startswith('test_'):
raise TypeError(f"the use of the `@covered_by` decorator is only allowed for `Scenario` objects "
f"and test methods of `Scenario` objects - the method `{owner.__name__}.{name}` "
f"does not start with `test_` and is not a valid test method")

if not hasattr(owner, '_covered_by'):
owner._covered_by = {}
if self.func not in owner._covered_by.keys():
owner._covered_by[self.func] = []
if item is None:
# reset it
owner._covered_by[self.func] = []
elif item not in owner._covered_by[self.func]:
owner._covered_by[self.func].append(item)
raise TypeError(f"the use of the `@covered_by` decorator is only allowed for test methods of "
f"`Scenario` objects - the method `{owner.__name__}.{name}` does not start with "
f"`test_` and is not a valid test method")
# if item is a string - resolve method
cleared_item = item
if isinstance(item, str):
cleared_item = getattr(owner, item)
scenario_controller = ScenarioController.get_for(owner)
scenario_controller.register_covered_by_for(meth=name, covered_by=cleared_item)
else:
raise TypeError(f"The use of the `@covered_by` decorator is not allowed for methods of a "
f"`{owner.__name__}`. You should only use this decorator for `Scenario` objects or "
f"valid test methods of a `Scenario` object")
f"`{owner.__name__}`. You should only use this decorator for valid test methods of a "
f"`Scenario` object")

setattr(owner, name, self.func)

Expand Down
64 changes: 2 additions & 62 deletions src/_balder/executor/scenario_executor.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from __future__ import annotations
from typing import Type, Union, List, Dict, TYPE_CHECKING
from typing import Type, Union, List, TYPE_CHECKING

from _balder.fixture_execution_level import FixtureExecutionLevel
from _balder.testresult import ResultState, BranchBodyResult
from _balder.utils import get_class_that_defines_method
from _balder.executor.basic_executable_executor import BasicExecutableExecutor
from _balder.executor.variation_executor import VariationExecutor
from _balder.previous_executor_mark import PreviousExecutorMark
Expand Down Expand Up @@ -137,71 +136,12 @@ def cleanup_empty_executor_branches(self, consider_discarded=False):
for cur_variation_executor in to_remove_executor:
self._variation_executors.remove(cur_variation_executor)

def get_covered_by_dict(self) -> Dict[Union[Type[Scenario], callable], List[Union[Type[Scenario], callable]]]:
"""
This method returns the complete resolved ``@covered_by`` dictionary for this scenario. It automatically
cleans up every inheritance of the covered_by decorators for every parent class of our scenario.
"""
def determine_most_inherited_class(class_list):
for cur_candidate in class_list:
candidate_is_valid = True
for cur_other_candidate in class_list:
if cur_candidate == cur_other_candidate:
pass
if not issubclass(cur_candidate, cur_other_candidate):
candidate_is_valid = False
break
if candidate_is_valid:
return cur_candidate
return None

# all data will be inherited while ``@covered_by`` overwrites elements only if there is a new decorator at the
# overwritten method
# -> we have to filter the dictionary and only return the value given for highest overwritten method
relative_covered_by_dict = {}
if hasattr(self.base_scenario_class, '_covered_by'):
function_name_mapping = {}
classes = []
for cur_key in self.base_scenario_class._covered_by.keys():
if issubclass(cur_key, Scenario):
# this is a covered_by definition for the whole class
classes.append(cur_key)
else:
# this is a covered_by definition for one test method
if cur_key.__name__ in function_name_mapping.keys():
function_name_mapping[cur_key.__name__] = [cur_key]
else:
function_name_mapping[cur_key.__name__].append(cur_key)

# determine the highest definition for class statement (only if necessary)
if len(classes) > 0:
most_inherited_class = determine_most_inherited_class(classes)
# this is the most inherited child -> add this definition
relative_covered_by_dict[most_inherited_class] = \
self.base_scenario_class._covered_by[most_inherited_class]

# determine the highest definition for every test method
for cur_function_name, cur_possible_candidates in function_name_mapping.items():
classes = [get_class_that_defines_method(meth) for meth in cur_possible_candidates]
most_inherited_class = determine_most_inherited_class(classes)
most_inherited_test_method = cur_possible_candidates[classes.index(most_inherited_class)]
# this is the most inherited test method -> add the definition of this one and replace the method with
# this Scenario's one
relative_covered_by_dict[getattr(self.base_scenario_class, cur_function_name)] = \
self.base_scenario_class._covered_by[most_inherited_test_method]
else:
pass
return relative_covered_by_dict

def get_covered_by_element(self) -> List[Union[Scenario, callable]]:
"""
This method returns a list of elements where the whole scenario is covered from. This means, that the whole
test methods in this scenario are already be covered from one of the elements in the list.
"""
covered_by_dict_resolved = self.get_covered_by_dict()
if self in covered_by_dict_resolved.keys():
return covered_by_dict_resolved[self]
return []
return self.base_scenario_controller.get_abs_covered_by_dict().get(None, [])

def add_variation_executor(self, variation_executor: VariationExecutor):
"""
Expand Down
19 changes: 12 additions & 7 deletions src/_balder/executor/testcase_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ def _body_execution(self, show_discarded):
if self.should_be_ignored():
self.body_result.set_result(ResultState.NOT_RUN)
return
if self.is_covered_by():
self.body_result.set_result(ResultState.COVERED_BY)
return

start_time = time.perf_counter()
try:
Expand Down Expand Up @@ -156,6 +159,10 @@ def should_be_ignored(self):
return True
return False

def is_covered_by(self):
"""returns true if the testcase is covered-by"""
return self.prev_mark == PreviousExecutorMark.COVERED_BY

def has_skipped_tests(self) -> bool:
return self.prev_mark == PreviousExecutorMark.SKIP

Expand All @@ -175,14 +182,12 @@ def get_covered_by_element(self) -> List[Union[Scenario, callable]]:
This method returns a list of elements where the whole scenario is covered from. This means, that the whole
test methods in this scenario are already be covered from every single element in the list.
"""
all_covered_by_data = []
scenario_executor = self.parent_executor.parent_executor
scenario_class = scenario_executor.base_scenario_class
covered_by_dict_resolved = scenario_executor.get_covered_by_dict()
if self.base_testcase_callable in covered_by_dict_resolved.keys():
all_covered_by_data += covered_by_dict_resolved[self.base_testcase_callable]
if scenario_class in covered_by_dict_resolved.keys():
all_covered_by_data += covered_by_dict_resolved[scenario_class]

covered_by_dict = scenario_executor.base_scenario_controller.get_abs_covered_by_dict()
all_covered_by_data = covered_by_dict.get(self.base_testcase_callable.__name__, [])
# also add all scenario specified covered-by elements
all_covered_by_data.extend(covered_by_dict.get(None, []))
return all_covered_by_data

def get_all_test_method_args(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,12 @@ def get_covered_by_element(self) -> List[Scenario | callable]:
This method returns a list of elements where the whole scenario is covered from. This means, that the whole
test methods in this scenario are already be covered from every single element in the list.
"""
all_covered_by_data = []
scenario_executor = self.parent_executor.parent_executor
scenario_class = scenario_executor.base_scenario_class
covered_by_dict_resolved = scenario_executor.get_covered_by_dict()
if self.base_testcase_callable in covered_by_dict_resolved.keys():
all_covered_by_data += covered_by_dict_resolved[self.base_testcase_callable]
if scenario_class in covered_by_dict_resolved.keys():
all_covered_by_data += covered_by_dict_resolved[scenario_class]

covered_by_dict = scenario_executor.base_scenario_controller.get_abs_covered_by_dict()
all_covered_by_data = covered_by_dict.get(self.base_testcase_callable.__name__, [])
# also add all scenario specified covered-by elements
all_covered_by_data.extend(covered_by_dict.get(None, []))
return all_covered_by_data

def get_testcase_executors(self) -> List[ParametrizedTestcaseExecutor | UnresolvedParametrizedTestcaseExecutor]:
Expand Down
4 changes: 3 additions & 1 deletion src/_balder/executor/variation_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,9 @@ def _body_execution(self, show_discarded):
return

for cur_testcase_executor in self.get_testcase_executors():
if cur_testcase_executor.has_runnable_tests() or cur_testcase_executor.has_skipped_tests():
if (cur_testcase_executor.has_runnable_tests()
or cur_testcase_executor.has_skipped_tests()
or cur_testcase_executor.has_covered_by_tests()):
cur_testcase_executor.execute()
else:
cur_testcase_executor.set_result_for_whole_branch(ResultState.NOT_RUN)
Expand Down
Empty file added tests/covered_by/__init__.py
Empty file.
Empty file.
Empty file.
Loading