A code autograder for student homework submissions
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

313 regels
7.5 KiB

  1. module KGrader
  2. class Submission
  3. attr_reader :course, :semester, :assignment, :student
  4. MAX_COLS = 79
  5. def initialize(filesystem, course, semester, assignment, student)
  6. @fs = filesystem
  7. @course = course
  8. @semester = semester
  9. @assignment = assignment
  10. @student = student
  11. @root = @fs.submission @course.name, @semester, @assignment.name, student
  12. @status = nil
  13. end
  14. def status
  15. @status ||= @fs.load(statusfile).to_sym
  16. end
  17. def status=(new_status)
  18. File.write statusfile, new_status
  19. @status = new_status
  20. end
  21. def exists?
  22. File.exists? statusfile
  23. end
  24. def create
  25. FileUtils.mkdir_p @root
  26. self.status = :init
  27. nil
  28. end
  29. def fetch(due)
  30. if status == :init
  31. @course.backend.clone repo, @semester, @assignment.id, @student
  32. rewind due
  33. self.status = :ungraded
  34. else
  35. oldrev = revision if status == :graded
  36. self.status = :fetching
  37. @course.backend.update repo
  38. newrev = rewind due
  39. self.status = newrev == oldrev ? :graded : :ungraded
  40. end
  41. nil
  42. end
  43. def grade(superscore = false)
  44. grade_prep superscore
  45. stage
  46. build
  47. test
  48. save
  49. grade_post
  50. @summary
  51. end
  52. def commit
  53. if status == :graded && File.exists?(pendingfile)
  54. message = @assignment.commit_message @student
  55. FileUtils.cp gradereport, File.join(repo, @assignment.report)
  56. @course.backend.commit repo, message, @assignment.report
  57. FileUtils.rm pendingfile
  58. end
  59. nil
  60. end
  61. # -------------------------------------------------------------------------
  62. private
  63. def repo
  64. File.join @root, 'repo'
  65. end
  66. def statusfile
  67. File.join @root, 'status.txt'
  68. end
  69. def gradefile
  70. File.join @root, 'grade.json'
  71. end
  72. def gradereport
  73. File.join @root, 'grade.txt'
  74. end
  75. def pendingfile
  76. File.join @root, 'pending'
  77. end
  78. def buildlog
  79. File.join @root, 'build.log'
  80. end
  81. def testlog
  82. File.join @root, 'test.log'
  83. end
  84. def revision
  85. @course.backend.revision repo
  86. end
  87. def rewind(date)
  88. log = @course.backend.log repo
  89. target = log.find { |commit| commit[:date] <= date }
  90. if target.nil?
  91. raise SubmissionError, "no commits before due date: #{student}"
  92. end
  93. rev = target[:rev]
  94. @course.backend.update repo, rev
  95. rev
  96. end
  97. # -------------------------------------------------------------------------
  98. def grade_prep(superscore)
  99. @done = false
  100. @failed = false
  101. @changed = !superscore || self.status == :ungraded
  102. @summary = nil
  103. @tests = @assignment.tests.clone.each do |test|
  104. test[:score] = 0
  105. test[:comments] = []
  106. end
  107. load_gradefile if superscore
  108. if superscore && @tests.all? { |test| test[:score] == test[:max] }
  109. @done = true
  110. return
  111. end
  112. self.status = :ungraded
  113. archive_logs superscore
  114. @fs.jail.reset
  115. @fs.jail.init
  116. end
  117. def stage
  118. return if @done
  119. @assignment.manifest[:provided].each do |entry|
  120. @fs.jail.stage entry[:path], entry[:name]
  121. end
  122. @assignment.manifest[:graded].each do |entry|
  123. @fs.jail.stage File.join(repo, entry[:name]), entry[:name]
  124. end
  125. end
  126. def build
  127. return if @done
  128. @assignment.build_steps.each do |command|
  129. return build_failure unless @fs.jail.exec command, buildlog
  130. end
  131. end
  132. def test
  133. return if @done
  134. @tests.each { |test| run_test test }
  135. end
  136. def save
  137. if @changed
  138. File.write gradefile, generate_gradefile
  139. File.write gradereport, generate_report
  140. FileUtils.touch pendingfile
  141. end
  142. end
  143. def grade_post
  144. self.status = :graded
  145. @fs.jail.reset
  146. @summary = generate_summary
  147. end
  148. # -------------------------------------------------------------------------
  149. def archive_logs(superscore)
  150. logs = [buildlog, testlog]
  151. if superscore
  152. logs.select { |log| File.exists? log }.each do |log|
  153. File.rename log, "#{log}.#{Time.now.strftime('%Y%m%d%H%M%S')}"
  154. end
  155. else
  156. FileUtils.rm_f logs
  157. end
  158. end
  159. def build_failure
  160. @done = true
  161. @failed = true
  162. end
  163. def run_test(test)
  164. return if test[:score] == text[:max] # Can't superscore the max score
  165. test[:depends].each do |depname|
  166. dep = @tests.find { |t| t[:name] == depname }
  167. if !dep.nil? && dep[:score] == 0
  168. test[:comments] = [test[:depfail]]
  169. return
  170. end
  171. end
  172. score, comments = @fs.jail.run_test test[:script], testlog
  173. if score > test[:score]
  174. test[:score] = score
  175. test[:comments] = comments
  176. @changed = true
  177. end
  178. end
  179. def load_gradefile
  180. begin
  181. data = @fs.load gradefile
  182. rescue FilesystemError
  183. return
  184. end
  185. data.each do |name, fields|
  186. test = @tests.find { |t| t[:name] == name }
  187. test[:score], test[:comments] = fields
  188. end
  189. end
  190. def generate_gradefile
  191. data = @tests.inject({}) do |hash, test|
  192. hash[test[:name]] = [test[:score], test[:comments]]
  193. hash
  194. end
  195. JSON.generate data
  196. end
  197. def generate_report
  198. header = "#{assignment.title} Grade Report for #{student}"
  199. header = header.center(MAX_COLS).rstrip
  200. hr1 = '-' * MAX_COLS
  201. hr2 = '=' * MAX_COLS
  202. metadata = [
  203. "commit revision: #{revision}",
  204. "commit date: #{format_time @course.backend.commit_date(repo)}",
  205. "grade date: #{format_time Time.now}"
  206. ]
  207. version = KGrader.version
  208. metadata.push "grader version: #{version}" if version
  209. metadata = metadata.join "\n"
  210. tests = "tests:\n" + @tests.map do |test|
  211. score = format_points(test[:score], test[:max], max_score)
  212. justify_both " - #{test[:name]}", score
  213. end.join("\n")
  214. total = justify_both "total:", format_points(score, max_score)
  215. comments = generate_comments
  216. [header, hr2, metadata, hr1, tests, hr1, total, hr1, comments].join "\n"
  217. end
  218. def generate_summary
  219. if !@changed
  220. line = "no change"
  221. elsif @failed
  222. line = "failed to compile"
  223. else
  224. line = @tests.map do |test|
  225. "#{test[:score].to_s.rjust get_span(test[:max])}/#{test[:max]}"
  226. end.join ', '
  227. end
  228. "#{format_points score, max_score}: #{line}"
  229. end
  230. def score
  231. @tests.reduce(0) { |sum, t| sum + t[:score] }
  232. end
  233. def max_score
  234. @tests.reduce(0) { |sum, t| sum + t[:max] }
  235. end
  236. def generate_comments
  237. comments = []
  238. comments.push "failed to compile" if @failed
  239. @tests.each do |test|
  240. test[:comments].each { |cmt| comments.push "#{test[:name]}: #{cmt}" }
  241. end
  242. comments.concat @assignment.extra_comments
  243. return "" if comments.empty?
  244. "comments:\n" + comments.map { |cmt| " - #{cmt}\n" }.join
  245. end
  246. def format_points(score, max, span_max = nil)
  247. percent = (score.to_f * 100 / max).round.to_s.rjust 3
  248. span = get_span(span_max || max)
  249. "#{percent}% (#{score.to_s.rjust span}/#{max.to_s.rjust span})"
  250. end
  251. def justify_both left, right
  252. "#{left}#{right.rjust MAX_COLS - left.length}"
  253. end
  254. def get_span(max)
  255. (Math.log10(max) + 1).to_i
  256. end
  257. def format_time(time)
  258. time.localtime.strftime "%H:%M, %b %d, %Y %Z"
  259. end
  260. end
  261. end