Coursemology/coursemology2

View on GitHub
lib/autoload/course/assessment/programming_package.rb

Summary

Maintainability
A
25 mins
Test Coverage
# frozen_string_literal: true
# Represents a programming package, containing the tests and submitted code for a given question.
#
# A package has these files at the very minimum:
#
# - +Makefile+: the makefile for building and executing the package. There are at least three
#   targets:
#   - +prepare+: for initialising the environment. This can be used to set up libraries or other
#     modifications to the package.
#   - +compile+: for building the package. For scripting languages, this is a no-op, but <b>must
#     still be defined</b>
#   - +test+: for testing the package. After completing the task, +tests.junit.xml+ must be found
#     in the root directory of the package (beside the +tests/+ directory)
# - +submission/+: where the template code/students' code will be placed. When this package is
#   uploaded by the instructor as part of a question, this directory will contain the templates
#   that will be used when a student first attempts the question. When this package is generated
#   as part of auto grading, then this contains all the student's submitted code.
# - +tests/+: where the tests will be placed. How this set of tests should be run is immaterial,
#   so long it is run when +make test+ is executed by the evaluator.
#
# It can also contain an optional 'solution' folder:
#
#   +solution/+: where syntax correct code is placed. When the package is uploaded by the
#   instructor as part of a question, the contents of this directory will replace the contents of
#   the 'submission' folder. This allows infinite loops or incorrect syntax to be used as templates
#   for the students to fix. It also allows solutions to be kept in the same place as the tests.
#   When this package is generated as part of auto grading, this folder is removed to prevent
#   student code from accessing it.
#
# Call {Course::Assessment::ProgrammingPackage#close} when changes have been made to persist the
# changes to disk. Duplicate the file before modifying if you do not want to modify the original
# file -- changes are made in-place.
class Course::Assessment::ProgrammingPackage
  # The path to the .meta file.
  META_PATH = Pathname.new('.meta').freeze

  # The path to the Makefile.
  MAKEFILE_PATH = Pathname.new('Makefile').freeze

  # The path to the submission.
  SUBMISSION_PATH = Pathname.new('submission').freeze

  # The path to the solution.
  SOLUTION_PATH = Pathname.new('solution').freeze

  # The path to the tests.
  TESTS_PATH = Pathname.new('tests').freeze

  # Creates a new programming package instance.
  #
  # @overload initialize(path)
  #   @param [String|Pathname] path The path to the package on disk.
  # @overload initialize(stream)
  #   @param [IO] stream The stream to the file.
  def initialize(path_or_stream)
    case path_or_stream
    when String, Pathname
      @path = path_or_stream
    when IO
      @stream = path_or_stream
    else
      raise ArgumentError, 'Invalid path or stream object'
    end
  end

  # Gets the file path to the provided package.
  #
  # @return [String] The path to the file.
  # @return [nil] If the package is associated with a stream.
  def path
    if @file
      @file.name
    elsif @path
      @path.to_s
    elsif @stream.is_a?(File)
      @stream.path
    end
  end

  # Closes the package.
  def close
    return if @file.nil?

    @file.close
    @file = nil
  end

  # Commits the package changes to disk
  #
  # @return [Boolean] True if the file was saved.
  def save
    ensure_file_open!
    @file.commit
    true
  end

  # Checks if the given programming package is valid.
  #
  # @return [Boolean]
  def valid?
    ensure_file_open!

    ['Makefile', 'submission/', 'tests/'].all? { |entry| @file.find_entry(entry).present? }
  end

  # Gets the .meta file.
  #
  # @return [String] Contents of the .meta file.
  def meta_file
    get_file(META_PATH)
  rescue StandardError
    nil
  end

  # Gets the contents of all submission files.
  #
  # @return [Hash<Pathname, String>] A hash mapping the file path to the file contents of each
  #   file.
  def submission_files
    get_folder_files(SUBMISSION_PATH)
  end

  # Replaces the contents of all submission files.
  #
  # @param [Hash<Pathname|String, String>] files A hash mapping the file path to the file
  #   contents of each file.
  def submission_files=(files)
    ensure_file_open!
    remove_folder_files(SUBMISSION_PATH)

    files.each do |path, file|
      path = Pathname.new(path) unless path.is_a?(Pathname)
      raise ArgumentError, 'Paths must be relative' unless path.relative?

      @file.get_output_stream(SUBMISSION_PATH.join(path)) do |stream|
        stream.write(file)
      end
    end
  end

  # Remove the contents of the solution folder so students can't do sneaky things
  # like generating a report from the solution folder.
  def remove_solution_files
    remove_folder_files(SOLUTION_PATH)
  end

  # Gets the contents of all solution files.
  #
  # @return [Hash<Pathname, String>] A hash mapping the file path to the file contents of each
  #   file.
  def solution_files
    get_folder_files(SOLUTION_PATH)
  end

  # If a solution directory exists, replace the contents of the submission directory
  # with the contents of the solution directory.
  # Allows syntax incorrect templates since the import uses other files.
  def replace_submission_with_solution
    # Return if there are no files in the solution directory
    files = solution_files
    return if files.empty?

    self.submission_files = files
  end

  # Unzips the contents of the file to the destination folder.
  #
  # @param [String] Path to folder.
  def unzip_file(destination)
    ensure_file_open!
    @file.each do |entry|
      entry_path = File.join(destination, entry.name)
      FileUtils.mkdir_p(File.dirname(entry_path))
      @file.extract(entry, entry_path) unless File.exist?(entry_path)
    end
  end

  # Gets the contents of all test files.
  #
  # @return [Hash<Pathname, String>] A hash mapping the file path to the file contents of each
  #   file.
  def test_files
    get_folder_files(TESTS_PATH)
  end

  # Gets the contents of all files in main dir.
  #
  # @return [Hash<Pathname, String>] A hash mapping the file path to the file contents of each
  #   file.
  def main_files
    retrieve_files_in_main_dir
  end

  private

  # Ensures that the zip file is open.
  #
  # When a stream is open, some atypical code is required because Rubyzip doesn't support streams
  # in its API too well -- the entries in memory and loaded from stream are different.
  #
  # @raise [IllegalStateError] when the zip file is not open and it cannot be opened.
  def ensure_file_open!
    return if @file

    if @path
      @file = Zip::File.open(@path.to_s)
    elsif @stream
      @file = Zip::File.new(@stream&.path, true)
      @file.read_from_stream(@stream)
      @file.instance_variable_set(:@stored_entries, @file.instance_variable_get(:@entry_set).dup)
    end
    raise IllegalStateError unless @file
  end

  def remove_folder_files(folder_path)
    @file.glob("#{folder_path}/**/*").each do |entry|
      @file.remove(entry)
    end
  end

  # Get the contents of all files in the specified folder.
  #
  # @param [String] Path to folder.
  # @return [Hash<Pathname, String>] A hash mapping the file path to the file contents of each
  #   file.
  def get_folder_files(folder_path)
    ensure_file_open!
    @file.glob("#{folder_path}/**/*").to_h do |entry|
      entry_file_name = Pathname.new(entry.name)
      file_name = entry_file_name.relative_path_from(folder_path)
      [file_name, entry.get_input_stream(&:read)]
    end
  end

  # Get the contents of a file.
  #
  # @param [String] Path to file.
  # @return [String]
  def get_file(file_path)
    ensure_file_open!
    @file.read(file_path)
  end

  # Get the contents of all files in the main directory of the package.
  #
  # @return [Hash<Pathname, String>] A hash mapping the file path to the file contents of each
  #   file.
  def retrieve_files_in_main_dir
    ensure_file_open!
    @file.glob('*').map do |entry|
      next if entry.directory?

      entry_file_name = Pathname.new(entry.name)
      [entry_file_name, entry.get_input_stream(&:read)]
    end.compact.to_h
  end
end