Skip to content

Commit

Permalink
Merge pull request #21 from slarse/refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
slarse committed Feb 24, 2019
2 parents b922770 + 26c88da commit dbc5a75
Show file tree
Hide file tree
Showing 5 changed files with 344 additions and 233 deletions.
2 changes: 2 additions & 0 deletions repomate_junit4/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from repomate_junit4.__version import __version__

SECTION = "junit4"
2 changes: 1 addition & 1 deletion repomate_junit4/__version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.3.0'
__version__ = "0.3.1"
228 changes: 228 additions & 0 deletions repomate_junit4/_java.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""Utility functions for activities related to Java.
This module contains utility functions dealing with Java-specific behavior, such
as parsing package statements from Java files and determining if a class is
abstract.
.. module:: _java
:synopsis: Utility functions for activities related to Java.
.. moduleauthor:: Simon Larsén
"""
import pathlib
import re
import os
import sys
import subprocess

from typing import Iterable, Tuple, Union, List

import repomate_plug as plug
from repomate_plug import Status

from repomate_junit4 import SECTION


def is_abstract_class(class_: pathlib.Path) -> bool:
"""Check if the file is an abstract class.
Args:
class_: Path to a Java class file.
Returns:
True if the class is abstract.
"""
assert class_.name.endswith(".java")
regex = r"^\s*?(public\s+)?abstract\s+class\s+{}".format(class_.name[:-5])
match = re.search(
regex, class_.read_text(encoding=sys.getdefaultencoding()), flags=re.MULTILINE
)
return match is not None


def generate_classpath(*paths: pathlib.Path, classpath: str = "") -> str:
"""Return a classpath including all of the paths provided. Always appends
the current working directory to the end.
Args:
paths: One or more paths to add to the classpath.
classpath: An initial classpath to append to.
Returns:
a formated classpath to be used with ``java`` and ``javac``
"""
for path in paths:
classpath += ":{!s}".format(path)

classpath += ":."
return classpath


def extract_package(class_: pathlib.Path) -> str:
"""Return the name package of the class. An empty string
denotes the default package.
"""
assert class_.name.endswith(".java")
# yes, $ is a valid character for a Java identifier ...
ident = r"[\w$][\w\d_$]*"
regex = r"^\s*?package\s+({ident}(.{ident})*);".format(ident=ident)
with class_.open(encoding=sys.getdefaultencoding(), mode="r") as file:
# package statement must be on the first line
first_line = file.readline()
matches = re.search(regex, first_line)
if matches:
return matches.group(1)
return ""


def fqn(package_name: str, class_name: str) -> str:
"""Return the fully qualified name (Java style) of the class.
Args:
package_name: Name of the package. The default package should be an
empty string.
class_name: Canonical name of the class.
Returns:
The fully qualified name of the class.
"""
return class_name if not package_name else "{}.{}".format(package_name, class_name)


def properly_packaged(path: pathlib.Path, package: str) -> bool:
"""Check if the path ends in a directory structure that corresponds to the
package.
Args:
path: Path to a Java file.
package: The name of a Java package.
Returns:
True iff the directory structure corresponds to the package name.
"""
required_dir_structur = package.replace(".", os.path.sep)
return str(path).endswith(required_dir_structur)


def extract_package_root(class_: pathlib.Path, package: str) -> pathlib.Path:
"""Return the package root, given that class_ is the path to a .java file.
If the package is the default package (empty string), simply return a copy
of class_.
Raise if the directory structure doesn't correspond to the package
statement.
"""
_check_directory_corresponds_to_package(class_.parent, package)
root = class_.parent
if package:
root = class_.parents[len(package.split("."))]
return root


def javac(
java_files: Iterable[Union[str, pathlib.Path]], classpath: str
) -> Tuple[str, str]:
"""Run ``javac`` on all of the specified files, assuming that they are
all ``.java`` files.
Args:
java_files: paths to ``.java`` files.
classpath: The classpath to set.
Returns:
(status, msg), where status is e.g. :py:const:`Status.ERROR` and
the message describes the outcome in plain text.
"""
command = ["javac", "-cp", classpath, *[str(path) for path in java_files]]
proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

if proc.returncode != 0:
status = Status.ERROR
msg = proc.stderr.decode(sys.getdefaultencoding())
else:
msg = "all files compiled successfully"
status = Status.SUCCESS

return status, msg


def pairwise_compile(
test_classes: List[pathlib.Path], java_files: List[pathlib.Path], classpath: str
) -> Tuple[List[plug.HookResult], List[plug.HookResult]]:
"""Compile test classes with their associated production classes.
For each test class:
1. Find the associated production class among the ``java_files``
2. Compile the test class together with all of the .java files in
the associated production class' directory.
Args:
test_classes: A list of paths to test classes.
java_files: A list of paths to java files from the student repo.
classpath: A base classpath to use.
Returns:
A tuple of lists of HookResults on the form ``(succeeded, failed)``
"""
failed = []
succeeded = []
# only use concrete test classes
concrete_test_classes = filter(lambda t: not is_abstract_class(t), test_classes)
for test_class in concrete_test_classes:
status, msg, prod_class_path = _pairwise_compile(
test_class, classpath, java_files
)
if status != Status.SUCCESS:
failed.append(plug.HookResult(SECTION, status, msg))
else:
succeeded.append((test_class, prod_class_path))

return succeeded, failed


def _pairwise_compile(test_class, classpath, java_files):
"""Compile the given test class together with its production class
counterpoint (if it can be found). Return a tuple of (status, msg).
"""
package = extract_package(test_class)
potential_prod_classes = _get_matching_prod_classes(test_class, package, java_files)

if len(potential_prod_classes) != 1:
status = Status.ERROR
msg = (
"no production class found for "
if not potential_prod_classes
else "multiple production classes found for "
) + fqn(package, test_class.name)
prod_class_path = None
else:
prod_class_path = potential_prod_classes[0]
adjacent_java_files = [
file
for file in prod_class_path.parent.glob("*.java")
if not file.name.endswith("Test.java")
] + list(test_class.parent.glob("*Test.java"))
status, msg = javac(
[*adjacent_java_files], generate_classpath(classpath=classpath)
)
return status, msg, prod_class_path


def _get_matching_prod_classes(test_class, package, java_files):
"""Find all production classes among the Java files that math the test
classes name and the package.
"""
prod_class_name = test_class.name.replace("Test.java", ".java")
return [
file
for file in java_files
if file.name == prod_class_name and extract_package(file) == package
]


def _check_directory_corresponds_to_package(path: pathlib.Path, package: str):
"""Check that the path ends in a directory structure that corresponds
to the package prefix.
"""
required_dir_structure = package.replace(".", os.path.sep)
if not str(path).endswith(required_dir_structure):
msg = (
"Directory structure does not conform to package statement. Dir:"
" '{}' Package: '{}'".format(path, package)
)
raise ValueError(msg)
92 changes: 92 additions & 0 deletions repomate_junit4/_junit4_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import pathlib
import re
import sys
import subprocess
import os

from typing import Tuple

from repomate_plug import Status

from repomate_junit4 import _java


def get_num_failed(test_output: bytes) -> int:
"""Get the amount of failed tests from the error output of JUnit4."""
decoded = test_output.decode(encoding=sys.getdefaultencoding())
match = re.search(r"Failures: (\d+)", decoded)
# TODO this is a bit unsafe, what if there is no match?
return int(match.group(1))


def parse_failed_tests(test_output: bytes) -> str:
"""Return a list of test failure descriptions, excluding stack traces."""
decoded = test_output.decode(encoding=sys.getdefaultencoding())
return re.findall(r"^\d\) .*(?:\n(?!\s+at).*)*", decoded, flags=re.MULTILINE)


def _extract_conforming_package(test_class, prod_class):
"""Extract a package name from the test and production class.
Raise if the test class and production class have different package
statements.
"""
test_package = _java.extract_package(test_class)
prod_package = _java.extract_package(prod_class)

if test_package != prod_package:
msg = (
"Test class {} in package {}, but production class {} in package {}"
).format(test_class.name, test_package, prod_class.name, prod_package)
raise ValueError(msg)

return test_package


def run_test_class(
test_class: pathlib.Path,
prod_class: pathlib.Path,
classpath: str,
verbose: bool = False,
) -> Tuple[str, str]:
"""Run a single test class on a single production class.
Args:
test_class: Path to a Java test class.
prod_class: Path to a Java production class.
classpath: A classpath to use in the tests.
verbose: Whether to output more failure information.
Returns:
()
"""
package = _extract_conforming_package(test_class, prod_class)

prod_class_dir = _java.extract_package_root(prod_class, package)
test_class_dir = _java.extract_package_root(test_class, package)

test_class_name = test_class.name[: -len(test_class.suffix)] # remove .java
test_class_name = _java.fqn(package, test_class_name)

classpath = _java.generate_classpath(
test_class_dir, prod_class_dir, classpath=classpath
)
command = ["java", "-cp", classpath, "org.junit.runner.JUnitCore", test_class_name]
proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

return _extract_results(proc, test_class_name, verbose)


def _extract_results(
proc: subprocess.CompletedProcess, test_class_name: str, verbose: bool
) -> Tuple[str, str]:
"""Extract and format results from a completed test run."""
if proc.returncode != 0:
status = Status.ERROR
msg = "Test class {} failed {} tests".format(
test_class_name, get_num_failed(proc.stdout)
)
if verbose:
msg += os.linesep + os.linesep.join(parse_failed_tests(proc.stdout))
else:
msg = "Test class {} passed!".format(test_class_name)
status = Status.SUCCESS
return status, msg
Loading

0 comments on commit dbc5a75

Please sign in to comment.