Source code for repomate_junit4._java

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


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