A code autograder for student homework submissions
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

313 lignes
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