"""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)]
)