""" A module to manage dependencies between pytest tests. This module provides the methods implementing the main logic. These are used in the pytest hooks that are in __init__.py. """ import collections import json import os from typing import Any, Generator import colorama import networkx from _pytest.nodes import Item from .constants import MARKER_KWARG_DEPENDENCIES, MARKER_NAME from .graphs import graph_interactive_network from .util import clean_nodeid, get_absolute_nodeid, get_markers, get_name class TestResult(object): """Keeps track of the results of a single test.""" STEPS = ["setup", "call", "teardown"] GOOD_OUTCOMES = ["passed"] def __init__(self, nodeid: str) -> None: """Create a new instance for a test with a given node id.""" self.nodeid = nodeid self.results: dict[str, Any] = {} def register_result(self, result: Any) -> None: """Register a result of this test.""" if result.when not in self.STEPS: raise ValueError( f"Received result for unknown step {result.when} of test {self.nodeid}" ) if result.when in self.results: raise AttributeError( f"Received multiple results for step {result.when} of test {self.nodeid}" ) self.results[result.when] = result.outcome @property def success(self) -> bool: """Whether the entire test was successful.""" return all( self.results.get(step, None) in self.GOOD_OUTCOMES for step in self.STEPS ) class TestDependencies(object): """Information about the resolved dependencies of a single test.""" def __init__(self, item: Item, manager: "DependencyManager") -> None: """Create a new instance for a given test.""" self.nodeid = clean_nodeid(item.nodeid) self.dependencies = set() self.unresolved = set() markers = get_markers(item, MARKER_NAME) dependencies = [ dep for marker in markers for dep in marker.kwargs.get(MARKER_KWARG_DEPENDENCIES, []) ] for dependency in dependencies: # If the name is not known, try to make it absolute (ie file::[class::]method) if dependency not in manager.name_to_nodeids: absolute_dependency = get_absolute_nodeid(dependency, self.nodeid) if absolute_dependency in manager.name_to_nodeids: dependency = absolute_dependency # Add all items matching the name if dependency in manager.name_to_nodeids: for nodeid in manager.name_to_nodeids[dependency]: self.dependencies.add(nodeid) else: self.unresolved.add(dependency) class DependencyManager(object): """Keep track of tests, their names and their dependencies.""" def __init__(self) -> None: """Create a new DependencyManager.""" self.options: dict[str, Any] = {} self._items: list[Item] | None = None self._name_to_nodeids: Any = None self._nodeid_to_item: Any = None self._results: Any = None @property def items(self) -> list[Item]: """The collected tests that are managed by this instance.""" if self._items is None: raise AttributeError("The items attribute has not been set yet") return self._items @items.setter def items(self, items: list[Item]) -> None: if self._items is not None: raise AttributeError("The items attribute has already been set") self._items = items self._name_to_nodeids = collections.defaultdict(list) self._nodeid_to_item = {} self._results = {} self._dependencies = {} for item in items: nodeid = clean_nodeid(item.nodeid) # Add the mapping from nodeid to the test item self._nodeid_to_item[nodeid] = item # Add the mappings from all names to the node id name = get_name(item) self._name_to_nodeids[name].append(nodeid) # Create the object that will contain the results of this test self._results[nodeid] = TestResult(clean_nodeid(item.nodeid)) # Don't allow using unknown keys on the name_to_nodeids mapping self._name_to_nodeids.default_factory = None for item in items: nodeid = clean_nodeid(item.nodeid) # Process the dependencies of this test # This uses the mappings created in the previous loop, and can thus not be merged into that loop self._dependencies[nodeid] = TestDependencies(item, self) @property def name_to_nodeids(self) -> dict[str, list[str]]: """A mapping from names to matching node id(s).""" assert self.items is not None return self._name_to_nodeids @property def nodeid_to_item(self) -> dict[str, Item]: """A mapping from node ids to test items.""" assert self.items is not None return self._nodeid_to_item @property def results(self) -> dict[str, TestResult]: """The results of the tests.""" assert self.items is not None return self._results @property def dependencies(self) -> dict[str, TestDependencies]: """The dependencies of the tests.""" assert self.items is not None return self._dependencies def print_name_map(self, verbose: bool = False) -> None: """Print a human-readable version of the name -> test mapping.""" print("Available dependency names:") for name, nodeids in sorted(self.name_to_nodeids.items(), key=lambda x: x[0]): if len(nodeids) == 1: if name == nodeids[0]: # This is just the base name, only print this when verbose if verbose: print(f" {name}") else: # Name refers to a single node id, so use the short format print(f" {name} -> {nodeids[0]}") else: # Name refers to multiple node ids, so use the long format print(f" {name} ->") for nodeid in sorted(nodeids): print(f" {nodeid}") def print_processed_dependencies(self, colors: bool = False) -> None: """Print a human-readable list of the processed dependencies.""" missing = "MISSING" if colors: missing = f"{colorama.Fore.RED}{missing}{colorama.Fore.RESET}" colorama.init() try: print("Dependencies:") for nodeid, info in sorted(self.dependencies.items(), key=lambda x: x[0]): descriptions = [] for dependency in info.dependencies: descriptions.append(dependency) for dependency in info.unresolved: descriptions.append(f"{dependency} ({missing})") if descriptions: print(f" {nodeid} depends on") for description in sorted(descriptions): print(f" {description}") finally: if colors: colorama.deinit() @property def sorted_items(self) -> Generator: """Get a sorted list of tests where all tests are sorted after their dependencies.""" # Build a directed graph for sorting build_skill_tree = os.getenv("BUILD_SKILL_TREE") BUILD_SKILL_TREE = ( build_skill_tree.lower() == "true" if build_skill_tree else False ) dag = networkx.DiGraph() # Insert all items as nodes, to prevent items that have no dependencies and are not dependencies themselves from # being lost dag.add_nodes_from(self.items) # Insert edges for all the dependencies for item in self.items: nodeid = clean_nodeid(item.nodeid) for dependency in self.dependencies[nodeid].dependencies: dag.add_edge(self.nodeid_to_item[dependency], item) labels = {} for item in self.items: try: with open(item.cls.CHALLENGE_LOCATION) as f: data = json.load(f) except: data = {} node_name = get_name(item) data["name"] = node_name labels[item] = data if BUILD_SKILL_TREE: # graph_spring_layout(dag, labels) graph_interactive_network(dag, labels, html_graph_path="") # Sort based on the dependencies return networkx.topological_sort(dag) def register_result(self, item: Item, result: Any) -> None: """Register a result of a test.""" nodeid = clean_nodeid(item.nodeid) self.results[nodeid].register_result(result) def get_failed(self, item: Item) -> Any: """Get a list of unfulfilled dependencies for a test.""" nodeid = clean_nodeid(item.nodeid) failed = [] for dependency in self.dependencies[nodeid].dependencies: result = self.results[dependency] if not result.success: failed.append(dependency) return failed def get_missing(self, item: Item) -> Any: """Get a list of missing dependencies for a test.""" nodeid = clean_nodeid(item.nodeid) return self.dependencies[nodeid].unresolved