autograder.submission
1import glob 2import os 3import subprocess 4import sys 5import traceback 6import typing 7 8import edq.util.dirent 9import edq.util.json 10import edq.util.serial 11import edq.util.time 12 13import autograder.assignment 14import autograder.fileop 15import autograder.filespec 16 17TEST_SUBMISSION_FILENAME: str = 'test-submission.json' 18GRADER_FILENAME: str = 'grader.py' 19GRADING_RESULT_FILENAME: str = 'result.json' 20 21CONFIG_KEY_STATIC_FILES: str = 'static-files' 22CONFIG_KEY_PRE_STATIC_OPS: str = 'pre-static-file-ops' 23CONFIG_KEY_POST_STATIC_OPS: str = 'post-static-file-ops' 24CONFIG_KEY_POST_SUB_OPS: str = 'post-submission-file-ops' 25 26INPUT_DIRNAME: str = 'input' 27OUTPUT_DIRNAME: str = 'output' 28WORK_DIRNAME: str = 'work' 29 30def copy_assignment_files( 31 source_dir: str, 32 dest_dir: str, 33 op_dir: str, 34 files: typing.List[str], 35 only_contents: bool = False, 36 pre_ops: typing.Union[typing.List[autograder.fileop.FileOp], None] = None, 37 post_ops: typing.Union[typing.List[autograder.fileop.FileOp], None] = None, 38 ) -> None: 39 """ 40 Copy over assignment files. 41 42 Full procedure:: 43 1) Do pre-copy operations. 44 2) Copy. 45 3) Do post-copy operations. 46 """ 47 48 if (pre_ops is None): 49 pre_ops = [] 50 51 if (post_ops is None): 52 post_ops = [] 53 54 # Do pre operations. 55 autograder.fileop.exec_file_operations(pre_ops, op_dir) 56 57 # Copy over the assignment's files. 58 for filespec_text in files: 59 spec = autograder.filespec.parse(filespec_text) 60 autograder.filespec.copy(spec, source_dir, dest_dir, only_contents) 61 62 # Do post operations. 63 autograder.fileop.exec_file_operations(post_ops, op_dir) 64 65def fetch_test_submissions(path: str) -> typing.List[str]: 66 """ 67 Fetch all test submission files (named TEST_SUBMISSION_FILENAME) within the given path, 68 or the path itself if it is already pointing to a test submission. 69 """ 70 71 path = os.path.abspath(path) 72 test_submissions = [] 73 74 if (os.path.isfile(path)): 75 if (os.path.basename(path) != TEST_SUBMISSION_FILENAME): 76 raise ValueError(f"Passed in submission is not named like a test submission ('{TEST_SUBMISSION_FILENAME}').") 77 78 test_submissions.append(path) 79 else: 80 test_submissions += glob.glob(os.path.join(path, '**', TEST_SUBMISSION_FILENAME), 81 recursive = True) 82 83 return test_submissions 84 85def prep_grading_dir( 86 assignment_config_path: str, 87 submission_dir: str, 88 grading_dir: typing.Union[str, None] = None, 89 skip_static: bool = False, 90 ) -> str: 91 """ 92 Create and return a directory for grading a submission. 93 94 Procedure: 95 1) If the base out dir is None, create a temp dir. 96 2) Create the three core directories (input/output/work) in the base dir. 97 3) Copy over the static files (includng pre/post operations). 98 4) Copy over the submission files (includng pre/post operations). 99 5) Return the dirs. 100 """ 101 102 if (grading_dir is None): 103 grading_dir = edq.util.dirent.get_temp_path(prefix = 'ag-py-submission-') 104 105 edq.util.dirent.mkdir(grading_dir) 106 107 input_dir, _, work_dir = make_core_dirs(grading_dir) 108 109 # Load the assignment config. 110 111 assignment_config_path = os.path.abspath(assignment_config_path) 112 assignment_base_dir = os.path.dirname(assignment_config_path) 113 114 try: 115 assignment_config = edq.util.json.load_path(assignment_config_path) 116 except Exception as ex: 117 raise ValueError("Failed to load assignment config: " + assignment_config_path) from ex 118 119 if (not skip_static): 120 # Copy static files. 121 copy_assignment_files(assignment_base_dir, work_dir, grading_dir, 122 assignment_config.get(CONFIG_KEY_STATIC_FILES, []), 123 pre_ops = assignment_config.get(CONFIG_KEY_PRE_STATIC_OPS, []), 124 post_ops = assignment_config.get(CONFIG_KEY_POST_STATIC_OPS, [])) 125 126 # Copy submission files. 127 copy_assignment_files(submission_dir, input_dir, grading_dir, 128 ['.'], only_contents = True, 129 pre_ops = [], 130 post_ops = assignment_config.get(CONFIG_KEY_POST_SUB_OPS, [])) 131 132 return grading_dir 133 134def make_core_dirs(base_dir: str) -> typing.Tuple[str, str, str]: 135 """ 136 Create and return the three core grading directories (input, output, work). 137 """ 138 139 input_dir = os.path.join(base_dir, INPUT_DIRNAME) 140 edq.util.dirent.mkdir(input_dir) 141 142 output_dir = os.path.join(base_dir, OUTPUT_DIRNAME) 143 edq.util.dirent.mkdir(output_dir) 144 145 work_dir = os.path.join(base_dir, WORK_DIRNAME) 146 edq.util.dirent.mkdir(work_dir) 147 148 return input_dir, output_dir, work_dir 149 150def run_test_submission(assignment_config_path: str, submission_config_path: str) -> bool: 151 """ Run a test submission and return if the output matches the expected submission output. """ 152 153 print(f"Testing assignment '{assignment_config_path}' and submission '{submission_config_path}'.") 154 155 grading_dir = prep_grading_dir(assignment_config_path, os.path.dirname(submission_config_path)) 156 157 old_module_keys = set() 158 try: 159 # Keep track of new top-level keys in sys.modules (imports) after the submission runs. 160 # This is to prevent any import of submission code that gets cached. 161 # This is in no way a complete solution, but also does not matter when run in Docker. 162 old_module_keys = set(sys.modules.keys()) 163 164 actual_result = run_submission(grading_dir, assignment_config_path = assignment_config_path) 165 finally: 166 new_module_keys = set(sys.modules.keys()) 167 for new_module_key in (new_module_keys - old_module_keys): 168 # Numpy and SciPy are special cases that are sensitive to reloads. 169 # Note that this would be a security concern (e.g., a submission hijacking numpy), 170 # but this is not used in docker-based grading. 171 if (new_module_key.startswith('numpy') or new_module_key.startswith('scipy')): 172 continue 173 174 if (new_module_key in sys.modules): 175 del sys.modules[new_module_key] 176 177 if (actual_result is None): 178 return False 179 180 return compare_test_submission(submission_config_path, actual_result) 181 182def compare_test_submission( 183 test_config_path: str, 184 actual_result: typing.Union[autograder.assignment.GradedAssignment, None], 185 print_result: bool = True, 186 ) -> bool: 187 """ 188 Compare a grading result against the expected output of a test submission. 189 Return true if the two match. 190 """ 191 192 if (actual_result is None): 193 print(f"Submission is null and cannot match expected output: '{test_config_path}'.") 194 return False 195 196 test_config = edq.util.json.load_path(test_config_path) 197 198 expected_result = autograder.assignment.GradedAssignment.from_dict(test_config['result']) 199 ignore_messages = test_config.get('ignore_messages', False) 200 201 match = actual_result.equals(expected_result, ignore_messages = ignore_messages) 202 203 if ((not match) and print_result): 204 print(f"Submission does not match expected output: '{test_config_path}'.") 205 print('Expected:') 206 print(expected_result.report(prefix = ' ')) 207 print('---') 208 print('Actual:') 209 print(actual_result.report(prefix = ' ')) 210 print('---') 211 212 return match 213 214def run_submission( 215 grading_dir: str, 216 assignment_config_path: typing.Union[str, None] = None, 217 grader_path: typing.Union[str, None] = None, 218 ) -> typing.Union[autograder.assignment.GradedAssignment, None]: 219 """ 220 Run a submission from a pre-populated grading directory and return the result. 221 The grader path (or default grader path) will be checked first for a Python grader (GRADER_FILENAME), 222 which will be run if it exists. 223 Otherwise, the assignment config will be checked for the grader. 224 """ 225 226 if (grader_path is None): 227 grader_path = os.path.join(grading_dir, WORK_DIRNAME, GRADER_FILENAME) 228 229 if (os.path.exists(grader_path)): 230 return run_python_grader(grader_path, grading_dir) 231 232 if (assignment_config_path is None): 233 raise ValueError("No assignment config path has been supplied for running a grader.") 234 235 return run_external_grader(assignment_config_path, grading_dir) 236 237def run_python_grader(grader_path: str, grading_dir: str) -> typing.Union[autograder.assignment.GradedAssignment, None]: 238 """ 239 Run a standard Python-based grader. 240 Returns None on grading failure. 241 """ 242 243 input_dir = os.path.join(grading_dir, INPUT_DIRNAME) 244 output_dir = os.path.join(grading_dir, OUTPUT_DIRNAME) 245 work_dir = os.path.join(grading_dir, WORK_DIRNAME) 246 247 edq.util.dirent.mkdir(work_dir) 248 249 # Ensure that the current directory is in sys.path. 250 sys.path.insert(0, '.') 251 252 # Move into the work dir for grading and back after. 253 start_dir = os.getcwd() 254 255 try: 256 os.chdir(work_dir) 257 258 assignment_class = autograder.assignment.fetch_assignment_class(grader_path) 259 if (assignment_class is None): 260 print("Failed to fetch assignment class from '{grader_path}'.") 261 return None 262 263 assignment = assignment_class(input_dir = input_dir, output_dir = output_dir, 264 work_dir = work_dir) 265 return assignment.grade() 266 except Exception: 267 print("Failed to run assignment ('{assignment_class.__name__}') on submission '{input_dir}': '{ex}'.") 268 traceback.print_exc() 269 return None 270 finally: 271 os.chdir(start_dir) 272 sys.path.pop(0) 273 274 return None 275 276def run_external_grader(assignment_config_path: str, grading_dir: str) -> autograder.assignment.GradedAssignment: 277 """ Run a grader that is not a standard Python-based grader. """ 278 279 work_dir = os.path.join(grading_dir, WORK_DIRNAME) 280 output_dir = os.path.join(grading_dir, OUTPUT_DIRNAME) 281 282 edq.util.dirent.mkdir(work_dir) 283 284 # Move into the work dir for grading and back after. 285 start_dir = os.getcwd() 286 287 try: 288 os.chdir(work_dir) 289 290 assignment_config = edq.util.json.load_path(assignment_config_path) 291 except Exception as ex: 292 raise ValueError("Failed to load assignment config: " + assignment_config_path) from ex 293 finally: 294 os.chdir(start_dir) 295 296 invocation = assignment_config.get('invocation', []) 297 if (len(invocation) == 0): 298 raise ValueError(("External (any grader not using the standard Python setup)" 299 + " graders must have a non-empty 'invocation' key in their assignment config.")) 300 301 subprocess.run(invocation, cwd = work_dir, check = True) 302 303 out_path = os.path.join(output_dir, GRADING_RESULT_FILENAME) 304 if (not os.path.isfile(out_path)): 305 raise ValueError(f"Could not find result after external grader ran: '{out_path}'.") 306 307 return autograder.assignment.GradedAssignment.from_path(out_path) 308 309class SubmissionSummary(edq.util.serial.DictConverter): 310 """ 311 A summary of a grading submission. 312 """ 313 314 def __init__(self, 315 id: str = '', 316 max_points: float = 0, 317 score: float = 0, 318 message: str = '', 319 grading_start_time: typing.Union[edq.util.time.Timestamp, int, None] = None, 320 **kwargs: typing.Any): 321 self.id: str = id 322 """ An identifier for this submission. """ 323 324 self.max_points: float = max_points 325 """ The maximum number of points possible for this assignment (excluding extra credit). """ 326 327 self.score: float = score 328 """ The score earned for this submission. """ 329 330 self.message: str = message 331 """ A message/feedback for the student. """ 332 333 if (grading_start_time is None): 334 grading_start_time = edq.util.time.Timestamp() 335 336 self.grading_start_time: typing.Any = edq.util.time.Timestamp(grading_start_time) 337 """ When grading started. """ 338 339 def short_id(self) -> str: 340 """ Get the short ID for this submission. """ 341 342 return self.id.split('::')[-1] 343 344 def __repr__(self) -> str: 345 message = '.' 346 if ((self.message is not None) and (self.message != '')): 347 message = f", Message: '{self.message}'." 348 349 return f"Submission ID: {self.short_id()}, Score: {self.score} / {self.max_points}, Time: {self.grading_start_time.pretty()}{message}"
31def copy_assignment_files( 32 source_dir: str, 33 dest_dir: str, 34 op_dir: str, 35 files: typing.List[str], 36 only_contents: bool = False, 37 pre_ops: typing.Union[typing.List[autograder.fileop.FileOp], None] = None, 38 post_ops: typing.Union[typing.List[autograder.fileop.FileOp], None] = None, 39 ) -> None: 40 """ 41 Copy over assignment files. 42 43 Full procedure:: 44 1) Do pre-copy operations. 45 2) Copy. 46 3) Do post-copy operations. 47 """ 48 49 if (pre_ops is None): 50 pre_ops = [] 51 52 if (post_ops is None): 53 post_ops = [] 54 55 # Do pre operations. 56 autograder.fileop.exec_file_operations(pre_ops, op_dir) 57 58 # Copy over the assignment's files. 59 for filespec_text in files: 60 spec = autograder.filespec.parse(filespec_text) 61 autograder.filespec.copy(spec, source_dir, dest_dir, only_contents) 62 63 # Do post operations. 64 autograder.fileop.exec_file_operations(post_ops, op_dir)
Copy over assignment files.
Full procedure:: 1) Do pre-copy operations. 2) Copy. 3) Do post-copy operations.
66def fetch_test_submissions(path: str) -> typing.List[str]: 67 """ 68 Fetch all test submission files (named TEST_SUBMISSION_FILENAME) within the given path, 69 or the path itself if it is already pointing to a test submission. 70 """ 71 72 path = os.path.abspath(path) 73 test_submissions = [] 74 75 if (os.path.isfile(path)): 76 if (os.path.basename(path) != TEST_SUBMISSION_FILENAME): 77 raise ValueError(f"Passed in submission is not named like a test submission ('{TEST_SUBMISSION_FILENAME}').") 78 79 test_submissions.append(path) 80 else: 81 test_submissions += glob.glob(os.path.join(path, '**', TEST_SUBMISSION_FILENAME), 82 recursive = True) 83 84 return test_submissions
Fetch all test submission files (named TEST_SUBMISSION_FILENAME) within the given path, or the path itself if it is already pointing to a test submission.
86def prep_grading_dir( 87 assignment_config_path: str, 88 submission_dir: str, 89 grading_dir: typing.Union[str, None] = None, 90 skip_static: bool = False, 91 ) -> str: 92 """ 93 Create and return a directory for grading a submission. 94 95 Procedure: 96 1) If the base out dir is None, create a temp dir. 97 2) Create the three core directories (input/output/work) in the base dir. 98 3) Copy over the static files (includng pre/post operations). 99 4) Copy over the submission files (includng pre/post operations). 100 5) Return the dirs. 101 """ 102 103 if (grading_dir is None): 104 grading_dir = edq.util.dirent.get_temp_path(prefix = 'ag-py-submission-') 105 106 edq.util.dirent.mkdir(grading_dir) 107 108 input_dir, _, work_dir = make_core_dirs(grading_dir) 109 110 # Load the assignment config. 111 112 assignment_config_path = os.path.abspath(assignment_config_path) 113 assignment_base_dir = os.path.dirname(assignment_config_path) 114 115 try: 116 assignment_config = edq.util.json.load_path(assignment_config_path) 117 except Exception as ex: 118 raise ValueError("Failed to load assignment config: " + assignment_config_path) from ex 119 120 if (not skip_static): 121 # Copy static files. 122 copy_assignment_files(assignment_base_dir, work_dir, grading_dir, 123 assignment_config.get(CONFIG_KEY_STATIC_FILES, []), 124 pre_ops = assignment_config.get(CONFIG_KEY_PRE_STATIC_OPS, []), 125 post_ops = assignment_config.get(CONFIG_KEY_POST_STATIC_OPS, [])) 126 127 # Copy submission files. 128 copy_assignment_files(submission_dir, input_dir, grading_dir, 129 ['.'], only_contents = True, 130 pre_ops = [], 131 post_ops = assignment_config.get(CONFIG_KEY_POST_SUB_OPS, [])) 132 133 return grading_dir
Create and return a directory for grading a submission.
Procedure: 1) If the base out dir is None, create a temp dir. 2) Create the three core directories (input/output/work) in the base dir. 3) Copy over the static files (includng pre/post operations). 4) Copy over the submission files (includng pre/post operations). 5) Return the dirs.
135def make_core_dirs(base_dir: str) -> typing.Tuple[str, str, str]: 136 """ 137 Create and return the three core grading directories (input, output, work). 138 """ 139 140 input_dir = os.path.join(base_dir, INPUT_DIRNAME) 141 edq.util.dirent.mkdir(input_dir) 142 143 output_dir = os.path.join(base_dir, OUTPUT_DIRNAME) 144 edq.util.dirent.mkdir(output_dir) 145 146 work_dir = os.path.join(base_dir, WORK_DIRNAME) 147 edq.util.dirent.mkdir(work_dir) 148 149 return input_dir, output_dir, work_dir
Create and return the three core grading directories (input, output, work).
151def run_test_submission(assignment_config_path: str, submission_config_path: str) -> bool: 152 """ Run a test submission and return if the output matches the expected submission output. """ 153 154 print(f"Testing assignment '{assignment_config_path}' and submission '{submission_config_path}'.") 155 156 grading_dir = prep_grading_dir(assignment_config_path, os.path.dirname(submission_config_path)) 157 158 old_module_keys = set() 159 try: 160 # Keep track of new top-level keys in sys.modules (imports) after the submission runs. 161 # This is to prevent any import of submission code that gets cached. 162 # This is in no way a complete solution, but also does not matter when run in Docker. 163 old_module_keys = set(sys.modules.keys()) 164 165 actual_result = run_submission(grading_dir, assignment_config_path = assignment_config_path) 166 finally: 167 new_module_keys = set(sys.modules.keys()) 168 for new_module_key in (new_module_keys - old_module_keys): 169 # Numpy and SciPy are special cases that are sensitive to reloads. 170 # Note that this would be a security concern (e.g., a submission hijacking numpy), 171 # but this is not used in docker-based grading. 172 if (new_module_key.startswith('numpy') or new_module_key.startswith('scipy')): 173 continue 174 175 if (new_module_key in sys.modules): 176 del sys.modules[new_module_key] 177 178 if (actual_result is None): 179 return False 180 181 return compare_test_submission(submission_config_path, actual_result)
Run a test submission and return if the output matches the expected submission output.
183def compare_test_submission( 184 test_config_path: str, 185 actual_result: typing.Union[autograder.assignment.GradedAssignment, None], 186 print_result: bool = True, 187 ) -> bool: 188 """ 189 Compare a grading result against the expected output of a test submission. 190 Return true if the two match. 191 """ 192 193 if (actual_result is None): 194 print(f"Submission is null and cannot match expected output: '{test_config_path}'.") 195 return False 196 197 test_config = edq.util.json.load_path(test_config_path) 198 199 expected_result = autograder.assignment.GradedAssignment.from_dict(test_config['result']) 200 ignore_messages = test_config.get('ignore_messages', False) 201 202 match = actual_result.equals(expected_result, ignore_messages = ignore_messages) 203 204 if ((not match) and print_result): 205 print(f"Submission does not match expected output: '{test_config_path}'.") 206 print('Expected:') 207 print(expected_result.report(prefix = ' ')) 208 print('---') 209 print('Actual:') 210 print(actual_result.report(prefix = ' ')) 211 print('---') 212 213 return match
Compare a grading result against the expected output of a test submission. Return true if the two match.
215def run_submission( 216 grading_dir: str, 217 assignment_config_path: typing.Union[str, None] = None, 218 grader_path: typing.Union[str, None] = None, 219 ) -> typing.Union[autograder.assignment.GradedAssignment, None]: 220 """ 221 Run a submission from a pre-populated grading directory and return the result. 222 The grader path (or default grader path) will be checked first for a Python grader (GRADER_FILENAME), 223 which will be run if it exists. 224 Otherwise, the assignment config will be checked for the grader. 225 """ 226 227 if (grader_path is None): 228 grader_path = os.path.join(grading_dir, WORK_DIRNAME, GRADER_FILENAME) 229 230 if (os.path.exists(grader_path)): 231 return run_python_grader(grader_path, grading_dir) 232 233 if (assignment_config_path is None): 234 raise ValueError("No assignment config path has been supplied for running a grader.") 235 236 return run_external_grader(assignment_config_path, grading_dir)
Run a submission from a pre-populated grading directory and return the result. The grader path (or default grader path) will be checked first for a Python grader (GRADER_FILENAME), which will be run if it exists. Otherwise, the assignment config will be checked for the grader.
238def run_python_grader(grader_path: str, grading_dir: str) -> typing.Union[autograder.assignment.GradedAssignment, None]: 239 """ 240 Run a standard Python-based grader. 241 Returns None on grading failure. 242 """ 243 244 input_dir = os.path.join(grading_dir, INPUT_DIRNAME) 245 output_dir = os.path.join(grading_dir, OUTPUT_DIRNAME) 246 work_dir = os.path.join(grading_dir, WORK_DIRNAME) 247 248 edq.util.dirent.mkdir(work_dir) 249 250 # Ensure that the current directory is in sys.path. 251 sys.path.insert(0, '.') 252 253 # Move into the work dir for grading and back after. 254 start_dir = os.getcwd() 255 256 try: 257 os.chdir(work_dir) 258 259 assignment_class = autograder.assignment.fetch_assignment_class(grader_path) 260 if (assignment_class is None): 261 print("Failed to fetch assignment class from '{grader_path}'.") 262 return None 263 264 assignment = assignment_class(input_dir = input_dir, output_dir = output_dir, 265 work_dir = work_dir) 266 return assignment.grade() 267 except Exception: 268 print("Failed to run assignment ('{assignment_class.__name__}') on submission '{input_dir}': '{ex}'.") 269 traceback.print_exc() 270 return None 271 finally: 272 os.chdir(start_dir) 273 sys.path.pop(0) 274 275 return None
Run a standard Python-based grader. Returns None on grading failure.
277def run_external_grader(assignment_config_path: str, grading_dir: str) -> autograder.assignment.GradedAssignment: 278 """ Run a grader that is not a standard Python-based grader. """ 279 280 work_dir = os.path.join(grading_dir, WORK_DIRNAME) 281 output_dir = os.path.join(grading_dir, OUTPUT_DIRNAME) 282 283 edq.util.dirent.mkdir(work_dir) 284 285 # Move into the work dir for grading and back after. 286 start_dir = os.getcwd() 287 288 try: 289 os.chdir(work_dir) 290 291 assignment_config = edq.util.json.load_path(assignment_config_path) 292 except Exception as ex: 293 raise ValueError("Failed to load assignment config: " + assignment_config_path) from ex 294 finally: 295 os.chdir(start_dir) 296 297 invocation = assignment_config.get('invocation', []) 298 if (len(invocation) == 0): 299 raise ValueError(("External (any grader not using the standard Python setup)" 300 + " graders must have a non-empty 'invocation' key in their assignment config.")) 301 302 subprocess.run(invocation, cwd = work_dir, check = True) 303 304 out_path = os.path.join(output_dir, GRADING_RESULT_FILENAME) 305 if (not os.path.isfile(out_path)): 306 raise ValueError(f"Could not find result after external grader ran: '{out_path}'.") 307 308 return autograder.assignment.GradedAssignment.from_path(out_path)
Run a grader that is not a standard Python-based grader.
310class SubmissionSummary(edq.util.serial.DictConverter): 311 """ 312 A summary of a grading submission. 313 """ 314 315 def __init__(self, 316 id: str = '', 317 max_points: float = 0, 318 score: float = 0, 319 message: str = '', 320 grading_start_time: typing.Union[edq.util.time.Timestamp, int, None] = None, 321 **kwargs: typing.Any): 322 self.id: str = id 323 """ An identifier for this submission. """ 324 325 self.max_points: float = max_points 326 """ The maximum number of points possible for this assignment (excluding extra credit). """ 327 328 self.score: float = score 329 """ The score earned for this submission. """ 330 331 self.message: str = message 332 """ A message/feedback for the student. """ 333 334 if (grading_start_time is None): 335 grading_start_time = edq.util.time.Timestamp() 336 337 self.grading_start_time: typing.Any = edq.util.time.Timestamp(grading_start_time) 338 """ When grading started. """ 339 340 def short_id(self) -> str: 341 """ Get the short ID for this submission. """ 342 343 return self.id.split('::')[-1] 344 345 def __repr__(self) -> str: 346 message = '.' 347 if ((self.message is not None) and (self.message != '')): 348 message = f", Message: '{self.message}'." 349 350 return f"Submission ID: {self.short_id()}, Score: {self.score} / {self.max_points}, Time: {self.grading_start_time.pretty()}{message}"
A summary of a grading submission.
315 def __init__(self, 316 id: str = '', 317 max_points: float = 0, 318 score: float = 0, 319 message: str = '', 320 grading_start_time: typing.Union[edq.util.time.Timestamp, int, None] = None, 321 **kwargs: typing.Any): 322 self.id: str = id 323 """ An identifier for this submission. """ 324 325 self.max_points: float = max_points 326 """ The maximum number of points possible for this assignment (excluding extra credit). """ 327 328 self.score: float = score 329 """ The score earned for this submission. """ 330 331 self.message: str = message 332 """ A message/feedback for the student. """ 333 334 if (grading_start_time is None): 335 grading_start_time = edq.util.time.Timestamp() 336 337 self.grading_start_time: typing.Any = edq.util.time.Timestamp(grading_start_time) 338 """ When grading started. """