Source code for repomate_junit4.junit4

"""Plugin that runs JUnit4 on test classes and corresponding production
classes.

.. important::

    Requires ``javac`` and ``java`` to be installed, and having
    ``hamcrest-core-1.3.jar`` and ``junit-4.12.jar`` on the local macine.

This plugin performs a fairly complicated tasks of running test classes from
pre-specified reference tests on production classes that are dynamically
discovered in student repositories. See the README for more details.

.. module:: javac
    :synopsis: Plugin that tries to compile all .java files in a repo.

.. moduleauthor:: Simon Larsén
"""
import itertools
import os
import argparse
import configparser
import pathlib
from typing import Union, Iterable, Tuple, List

import daiquiri
from colored import bg, style

import repomate_plug as plug
from repomate_plug import Status

from repomate_junit4 import _java
from repomate_junit4 import _junit4_runner
from repomate_junit4 import SECTION

LOGGER = daiquiri.getLogger(__file__)

ResultPair = Tuple[pathlib.Path, pathlib.Path]

DEFAULT_LINE_LIMIT = 150


class _ActException(Exception):
    """Raise if something goes wrong in act_on_clone_repo."""

    def __init__(self, hook_result):
        self.hook_result = hook_result


[docs]class JUnit4Hooks(plug.Plugin): def __init__(self): self._master_repo_names = [] self._reference_tests_dir = "" self._ignore_tests = [] self._hamcrest_path = "" self._junit_path = "" self._classpath = os.getenv("CLASSPATH") or "" self._verbose = False self._very_verbose = False self._disable_security = False
[docs] def act_on_cloned_repo( self, path: Union[str, pathlib.Path] ) -> plug.HookResult: """Look for production classes in the student repo corresponding to test classes in the reference tests directory. Assumes that all test classes end in ``Test.java`` and that there is a directory with the same name as the master repo in the reference tests directory. Args: path: Path to the student repo. Returns: a plug.HookResult specifying the outcome. """ assert self._master_repo_names assert self._reference_tests_dir try: path = pathlib.Path(path) if not path.exists(): return plug.HookResult( SECTION, Status.ERROR, "student repo {!s} does not exist".format(path), ) compile_succeeded, compile_failed = self._compile_all(path) tests_succeeded, tests_failed = self._run_tests(compile_succeeded) msg = self._format_results( itertools.chain(tests_succeeded, tests_failed, compile_failed) ) status = ( Status.ERROR if tests_failed or compile_failed else Status.SUCCESS ) return plug.HookResult(SECTION, status, msg) except _ActException as exc: return exc.hook_result except Exception as exc: raise _ActException( plug.HookResult(SECTION, Status.ERROR, str(exc)) )
[docs] def parse_args(self, args: argparse.Namespace) -> None: """Get command line arguments. Args: args: The full namespace returned by :py:func:`argparse.ArgumentParser.parse_args` """ self._master_repo_names = args.master_repo_names self._reference_tests_dir = ( args.reference_tests_dir if args.reference_tests_dir else self._reference_tests_dir ) self._ignore_tests = ( args.ignore_tests if args.ignore_tests else self._ignore_tests ) self._hamcrest_path = ( args.hamcrest_path if args.hamcrest_path else self._hamcrest_path ) self._junit_path = ( args.junit_path if args.junit_path else self._junit_path ) self._verbose = args.verbose self._very_verbose = args.very_verbose self._disable_security = ( args.disable_security if args.disable_security else self._disable_security )
[docs] def clone_parser_hook( self, clone_parser: configparser.ConfigParser ) -> None: """Add reference_tests_dir argument to parser. Args: clone_parser: The ``clone`` subparser. """ clone_parser.add_argument( "-rtd", "--reference-tests-dir", help="Path to a directory with reference tests.", type=str, required=not self._reference_tests_dir, ) clone_parser.add_argument( "-i", "--ignore-tests", help="Names of test classes to ignore.", type=str, nargs="+", ) clone_parser.add_argument( "-ham", "--hamcrest-path", help="Absolute path to the `{}` library.".format( _junit4_runner.HAMCREST_JAR ), type=str, # required if not picked up in config_hook nor on classpath required=not self._hamcrest_path and _junit4_runner.HAMCREST_JAR not in self._classpath, ) clone_parser.add_argument( "-junit", "--junit-path", help="Absolute path to the `{}` library.".format( _junit4_runner.JUNIT_JAR ), type=str, # required if not picked up in config_hook nor on classpath required=not self._junit_path and _junit4_runner.JUNIT_JAR not in self._classpath, ) clone_parser.add_argument( "--disable-security", help=( "Disable the default security policy (student code can do " "whatever)." ), action="store_true", ) verbosity = clone_parser.add_mutually_exclusive_group() verbosity.add_argument( "-v", "--verbose", help="Display more information about test failures.", action="store_true", ) verbosity.add_argument( "-vv", "--very-verbose", help="Display the full failure output, without truncating.", action="store_true", )
[docs] def config_hook(self, config_parser: configparser.ConfigParser) -> None: """Look for hamcrest and junit paths in the config, and get the classpath. Args: config: the config parser after config has been read. """ self._hamcrest_path = config_parser.get( SECTION, "hamcrest_path", fallback=self._hamcrest_path ) self._junit_path = config_parser.get( SECTION, "junit_path", fallback=self._junit_path ) self._reference_tests_dir = config_parser.get( SECTION, "reference_tests_dir", fallback=self._reference_tests_dir )
def _compile_all( self, path: pathlib.Path ) -> Tuple[List[ResultPair], List[plug.HookResult]]: """Attempt to compile all java files in the repo. Returns: a tuple of lists ``(succeeded, failed)``, where ``succeeded`` are tuples on the form ``(test_class, prod_class)`` paths. """ java_files = list(path.rglob("*.java")) master_name = self._extract_master_repo_name(path) test_classes = self._find_test_classes(master_name) compile_succeeded, compile_failed = _java.pairwise_compile( test_classes, java_files, classpath=self._generate_classpath() ) return compile_succeeded, compile_failed def _extract_master_repo_name(self, path: pathlib.Path) -> str: """Extract the master repo name from the student repo at ``path``. For this to work, the corresponding master repo name must be in self._master_repo_names. Args: path: path to the student repo Returns: the name of the associated master repository """ matches = list(filter(path.name.endswith, self._master_repo_names)) if len(matches) == 1: return matches[0] else: msg = ( "no master repo name matching the student repo" if not matches else "multiple matching master repo names: {}".format( ", ".join(matches) ) ) res = plug.HookResult(SECTION, Status.ERROR, msg) raise _ActException(res) def _find_test_classes(self, master_name) -> List[pathlib.Path]: """Find all test classes (files ending in ``Test.java``) in directory at <reference_tests_dir>/<master_name>. Args: master_name: Name of a master repo. Returns: a list of test classes from the corresponding reference test directory. """ test_dir = pathlib.Path(self._reference_tests_dir) / master_name if not (test_dir.exists() and test_dir.is_dir()): res = plug.HookResult( SECTION, Status.ERROR, "no reference test directory for {} in {}".format( master_name, self._reference_tests_dir ), ) raise _ActException(res) test_classes = [ file for file in test_dir.rglob("*.java") if file.name.endswith("Test.java") and file.name not in self._ignore_tests ] if not test_classes: res = plug.HookResult( SECTION, Status.WARNING, "no files ending in `Test.java` found in {!s}".format( test_dir ), ) raise _ActException(res) return test_classes def _format_results(self, hook_results: Iterable[plug.HookResult]): """Format a list of plug.HookResult tuples as a nice string. Args: hook_results: A list of plug.HookResult tuples. Returns: a formatted string """ backgrounds = { Status.ERROR: bg("red"), Status.WARNING: bg("yellow"), Status.SUCCESS: bg("dark_green"), } def test_result_string(status, msg): return "{}{}:{} {}".format( backgrounds[status], status, style.RESET, _truncate_lines(msg) if self._verbose else msg, ) return os.linesep.join( [ test_result_string(status, msg) for _, status, msg in hook_results ] ) def _run_tests( self, test_prod_class_pairs: ResultPair ) -> Tuple[List[plug.HookResult], List[plug.HookResult]]: """Run tests and return the results. Args: test_prod_class_pairs: A list of tuples on the form ``(test_class_path, prod_class_path)`` Returns: A tuple of lists ``(succeeded, failed)`` containing HookResult tuples. """ succeeded = [] failed = [] classpath = self._generate_classpath() with _junit4_runner.security_policy( classpath, active=not self._disable_security ) as security_policy: for test_class, prod_class in test_prod_class_pairs: status, msg = _junit4_runner.run_test_class( test_class, prod_class, classpath=classpath, verbose=self._verbose or self._very_verbose, security_policy=security_policy, ) if status != Status.SUCCESS: failed.append(plug.HookResult(SECTION, status, msg)) else: succeeded.append(plug.HookResult(SECTION, status, msg)) return succeeded, failed def _generate_classpath(self, *paths: pathlib.Path) -> str: """ Args: paths: One or more paths to add to the classpath. Returns: a formated classpath to be used with ``java`` and ``javac`` """ warn = ( "`{}` is not configured and not on the CLASSPATH variable." "This will probably crash." ) if not ( self._hamcrest_path or _junit4_runner.HAMCREST_JAR in self._classpath ): LOGGER.warning(warn.format(_junit4_runner.HAMCREST_JAR)) if not ( self._junit_path or _junit4_runner.JUNIT_JAR in self._classpath ): LOGGER.warning(warn.format(_junit4_runner.JUNIT_JAR)) paths = list(paths) if self._hamcrest_path: paths.append(self._hamcrest_path) if self._junit_path: paths.append(self._junit_path) return _java.generate_classpath(*paths, classpath=self._classpath)
def _truncate_lines(string: str, max_len: int = DEFAULT_LINE_LIMIT): """Truncate lines to max_len characters.""" trunc_msg = " #[...]# " if max_len <= len(trunc_msg): raise ValueError( "max_len must be greater than {}".format(len(trunc_msg)) ) effective_len = max_len - len(trunc_msg) head_len = effective_len // 2 tail_len = effective_len // 2 def truncate(s): if len(s) > max_len: return s[:head_len] + trunc_msg + s[-tail_len:] return s return os.linesep.join( [truncate(line) for line in string.split(os.linesep)] )