Source code for pedal.core.submission

"""
Representation of a student's submission to pedal. Almost certainly contains their code,
but may also contain other metadata.


TODO: Normalize the concept of evaluations ("<stdin>" or "evaluations").
get_program(filename='<stdin>') => submission.files['<stdin>']
get_evaluation()

"""

__all__ = ['Submission']

from pedal.utilities.files import get_extension

DEFAULT_EXTENSION = "py"


def python_submission_parser(code: str, filename: str) -> (str, str):
    return code, filename


def create_ipynb_submission_parser(metadata_filter=None):
    def ipynb_submission_parser(code: str, filename: str) -> (str, str):
        # TODO: What happens if we have a syntax error in the file?
        # TODO: Consider making a fallback JSON parser, since the structure is known.
        try:
            import nbformat
        except ImportError:
            raise ImportError("You have attempted to read an IPYNB file, but did not install `nbformat`. Is Jupyter installed?")
        nb = nbformat.reads(code, as_version=nbformat.current_nbformat)
        result = []
        for cell in nb.cells:
            if metadata_filter is not None and not metadata_filter(cell, nb.cells):
                continue
            if cell.cell_type == 'markdown':
                result.append(f'"""{cell.source}"""')
            elif cell.cell_type == 'code':
                result.append(cell.source)
        return "\n".join(result), filename
    return ipynb_submission_parser


[docs] class Submission: """ Simple class for holding information about the student's submission. Examples: A very simple example of creating a Submission with a single file:: >>> Submission({'answer.py': "a = 0"}) Attributes: files (`dict` mapping `str` to `str`): Dictionary of filenames mapped to their contents, emulating a file system. main_file (str): The entry point file that will be considered the main file. main_code (str): The actual code to run; if None, then defaults to the code of the main file. Useful for tools that want to change the currently active code (e.g., Source's sections) or run additional commands (e.g., Sandboxes' call). user (dict): Additional information about the user. assignment (dict): Additional information about the assignment. course (dict): Additional information about the course. execution (dict): Additional information about the results of executing the students' code. """ PARSERS = { "py": python_submission_parser, "ipynb": create_ipynb_submission_parser() } def __init__(self, files=None, main_file='answer.py', main_code=None, user=None, assignment=None, course=None, execution=None, instructor_file='instructor_tests.py', load_error=None): if files is None: files = {} self.files = files self.main_file = main_file self.load_error = load_error if main_code is not None: self.main_code = main_code if not self.main_code: self.main_code = "" if self.main_file not in self.files: self.main_code, self.main_file = self._preprocess_file(self.main_code, self.main_file) self.files[self.main_file] = self.main_code else: self.files[self.main_file], self.main_file = self._preprocess_file(self.files[self.main_file], self.main_file) # TODO: Non-main files are not going to be pre-processed; is that a problem for anyone? self._original_main_code = self.main_code self.user = user self.assignment = assignment self.course = course self.execution = execution self.instructor_file = instructor_file self.line_offsets = {} self._lines_cache = {}
[docs] def get_lines(self, filename=None): """ Retrieves the lines of code from this submission. Returns: list[str]: The lines of code for this submission. """ if filename is None: code = self.main_code else: code = self.files[filename] if code not in self._lines_cache: self._lines_cache[code] = code.split("\n") return self._lines_cache[code]
[docs] def get_files_lines(self): """ Retrieves a dictionary of lists of strings representing the files' lines of code. """ return {filename: self.get_lines(filename) for filename in self.files}
[docs] def replace_main(self, code: str, file: str = None): """ Substitutes the current main code and filename with the given arguments. Args: code (str): The new code to substitute in. file (str): An optional filename to use. """ self.main_code = code if file is not None: self.main_file = file code, self.main_file = self._preprocess_file(code, self.main_file) self.files[self.main_file] = code
def _preprocess_file(self, code, filename): """ Give the system a chance to potentially preprocess the given file, based on its extension. For example, to turn an IPYNB file into more conventional python code. Args: code: filename: Returns: """ extension = get_extension(filename, DEFAULT_EXTENSION) parser = self.PARSERS.get(extension, python_submission_parser) return parser(code, filename) @property def main_code(self): return self.files.get(self.main_file, "") @main_code.setter def main_code(self, code): self.files[self.main_file] = code
[docs] def set_line_offset(self, lineno, filename=None): """ Sets the line offset for the given filename. Defaults to main file.""" if filename is None: filename = self.main_file self.line_offsets[filename] = lineno
def clear_line_offsets(self): self.line_offsets.clear() def to_json(self): return dict( user=self.user, assignment=self.assignment, course=self.course, execution=self.execution, files=self.files.copy() )