-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #21 from slarse/refactor
- Loading branch information
Showing
5 changed files
with
344 additions
and
233 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
from repomate_junit4.__version import __version__ | ||
|
||
SECTION = "junit4" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
__version__ = '0.3.0' | ||
__version__ = "0.3.1" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.