-
Notifications
You must be signed in to change notification settings - Fork 78
Expand file tree
/
Copy pathprogramming_package.rb
More file actions
270 lines (238 loc) · 8.45 KB
/
Copy pathprogramming_package.rb
File metadata and controls
270 lines (238 loc) · 8.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# 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(file)
# @param [File] file The file object.
def initialize(path_or_file)
case path_or_file
when String, Pathname
@path = path_or_file
when File
@file_stream_obj = path_or_file
else
raise ArgumentError, 'Invalid path or File 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 @file_stream_obj
@file_stream_obj.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(destination)
@file.extract(entry, destination_directory: destination) 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
# Gets the contents of the XML test reports (if present).
# Under normal circumstances, they will not be present in the evaluation package,
# but current implementation of Codaveri-only evaluations requires them.
def test_reports
ensure_file_open!
reports_map = {}
[:public, :private, :evaluation].each do |test_type|
if @file.find_entry("report-#{test_type}.xml").present?
reports_map[test_type] =
@file.read("report-#{test_type}.xml")
end
end
reports_map
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 @file_stream_obj
@file = Zip::File.open_buffer(@file_stream_obj)
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