assignment-codeval 0.0.24__py3-none-any.whl → 0.0.26__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assignment_codeval/cli.py +2 -0
- assignment_codeval/evaluate.py +317 -51
- assignment_codeval/export_tests.py +187 -0
- assignment_codeval/submissions.py +192 -20
- assignment_codeval/test_template.html +111 -0
- {assignment_codeval-0.0.24.dist-info → assignment_codeval-0.0.26.dist-info}/METADATA +2 -1
- {assignment_codeval-0.0.24.dist-info → assignment_codeval-0.0.26.dist-info}/RECORD +10 -8
- {assignment_codeval-0.0.24.dist-info → assignment_codeval-0.0.26.dist-info}/WHEEL +0 -0
- {assignment_codeval-0.0.24.dist-info → assignment_codeval-0.0.26.dist-info}/entry_points.txt +0 -0
- {assignment_codeval-0.0.24.dist-info → assignment_codeval-0.0.26.dist-info}/top_level.txt +0 -0
assignment_codeval/cli.py
CHANGED
|
@@ -5,6 +5,7 @@ from assignment_codeval.check_grading import check_grading
|
|
|
5
5
|
from assignment_codeval.commons import set_config
|
|
6
6
|
from assignment_codeval.create_assignment import create_assignment
|
|
7
7
|
from assignment_codeval.evaluate import run_evaluation
|
|
8
|
+
from assignment_codeval.export_tests import export_tests
|
|
8
9
|
from assignment_codeval.github_connect import github_setup_repo
|
|
9
10
|
from assignment_codeval.install_assignment import install_assignment
|
|
10
11
|
from assignment_codeval.recent_comments import recent_comments
|
|
@@ -26,6 +27,7 @@ cli.add_command(list_codeval_assignments)
|
|
|
26
27
|
cli.add_command(install_assignment)
|
|
27
28
|
cli.add_command(recent_comments)
|
|
28
29
|
cli.add_command(check_grading)
|
|
30
|
+
cli.add_command(export_tests)
|
|
29
31
|
cli.add_command(get_benchmark_command())
|
|
30
32
|
|
|
31
33
|
if __name__ == "__main__":
|
assignment_codeval/evaluate.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#! /usr/bin/python3
|
|
2
2
|
|
|
3
|
+
import ast
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
import subprocess
|
|
@@ -27,6 +28,7 @@ def get_testing_path(filename: str) -> str:
|
|
|
27
28
|
test_args = ""
|
|
28
29
|
cmps = []
|
|
29
30
|
timeout_val = 10
|
|
31
|
+
output_length_limit = 4096
|
|
30
32
|
expected_exit_code = -1
|
|
31
33
|
test_case_count = 0
|
|
32
34
|
test_case_hint = ""
|
|
@@ -80,52 +82,178 @@ def compile_code(compile_command):
|
|
|
80
82
|
sys.exit(1)
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
###########################################################
|
|
86
|
+
# Function detection helpers
|
|
87
|
+
###########################################################
|
|
86
88
|
|
|
87
|
-
Arguments:
|
|
88
|
-
function_name: the function name to check files for usage of
|
|
89
|
-
*files: the files to check for the function name
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
def _detect_language(files):
|
|
91
|
+
"""Return 'c_cpp', 'java', 'python', or 'unknown' based on file extensions."""
|
|
92
|
+
for f in files:
|
|
93
|
+
ext = os.path.splitext(f)[1].lower()
|
|
94
|
+
if ext in ('.c', '.cpp', '.cc', '.cxx', '.h', '.hpp'):
|
|
95
|
+
return 'c_cpp'
|
|
96
|
+
if ext == '.java':
|
|
97
|
+
return 'java'
|
|
98
|
+
if ext == '.py':
|
|
99
|
+
return 'python'
|
|
100
|
+
return 'unknown'
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _find_compiled_artifact(source_file):
|
|
104
|
+
"""Return the compiled artifact for a source file, or None if not found.
|
|
105
|
+
|
|
106
|
+
Checks (in order): same base name with .o, .exe, and no extension.
|
|
93
107
|
"""
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
base = os.path.splitext(source_file)[0]
|
|
109
|
+
for candidate in (base + '.o', base + '.exe', base):
|
|
110
|
+
if os.path.exists(candidate):
|
|
111
|
+
return candidate
|
|
112
|
+
return None
|
|
98
113
|
|
|
99
|
-
# Match:
|
|
100
|
-
# [1] start of line OR non-identifier character
|
|
101
|
-
# [2] function name
|
|
102
|
-
# [3] optional whitespace
|
|
103
|
-
# [4] opening parenthesis (function call)
|
|
104
|
-
regex = rf'(^|[^[:alnum:]_]){function_name}[[:space:]]*\('
|
|
105
114
|
|
|
106
|
-
|
|
115
|
+
def _function_used_in_c_cpp(function_name, files):
|
|
116
|
+
"""Use objdump on compiled artifacts to detect C/C++ function usage.
|
|
117
|
+
|
|
118
|
+
Returns True if found, False if artifact exists but function absent,
|
|
119
|
+
None if no compiled artifact was found (caller should fall back).
|
|
120
|
+
"""
|
|
121
|
+
found_artifact = False
|
|
122
|
+
for source_file in files:
|
|
123
|
+
artifact = _find_compiled_artifact(source_file)
|
|
124
|
+
if artifact is None:
|
|
125
|
+
continue
|
|
126
|
+
found_artifact = True
|
|
127
|
+
# objdump -t dumps the symbol table; pipe through c++filt to demangle C++ names.
|
|
128
|
+
# Mangled C++ names embed the original identifier, so grep -w finds exact matches.
|
|
129
|
+
cmd = f"objdump -t '{artifact}' | c++filt | grep -w '{function_name}'"
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
["bash", "-c", cmd],
|
|
132
|
+
stdout=subprocess.PIPE,
|
|
133
|
+
stderr=subprocess.PIPE,
|
|
134
|
+
)
|
|
135
|
+
if result.returncode == 0:
|
|
136
|
+
return True
|
|
137
|
+
if not found_artifact:
|
|
138
|
+
return None
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _function_used_in_java(function_name, files):
|
|
143
|
+
"""Use javap on compiled .class files to detect Java method usage.
|
|
144
|
+
|
|
145
|
+
Returns True if found, False if class file exists but method absent,
|
|
146
|
+
None if no .class file was found (caller should fall back).
|
|
147
|
+
"""
|
|
148
|
+
found_class = False
|
|
149
|
+
for source_file in files:
|
|
150
|
+
base = os.path.splitext(source_file)[0]
|
|
151
|
+
class_file = base + '.class'
|
|
152
|
+
if not os.path.exists(class_file):
|
|
153
|
+
continue
|
|
154
|
+
found_class = True
|
|
155
|
+
# javap -c disassembles bytecode; method invocations reference the method name.
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
["javap", "-c", class_file],
|
|
158
|
+
stdout=subprocess.PIPE,
|
|
159
|
+
stderr=subprocess.PIPE,
|
|
160
|
+
)
|
|
161
|
+
if function_name.encode() in result.stdout:
|
|
162
|
+
return True
|
|
163
|
+
if not found_class:
|
|
164
|
+
return None
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _function_used_in_python(function_name, files):
|
|
169
|
+
"""Use the ast module to detect function calls in Python source files.
|
|
170
|
+
|
|
171
|
+
Returns True if a matching call is found, False otherwise.
|
|
172
|
+
Handles both direct calls (func()) and attribute calls (obj.func()).
|
|
173
|
+
"""
|
|
174
|
+
for source_file in files:
|
|
175
|
+
try:
|
|
176
|
+
with open(source_file, 'r') as f:
|
|
177
|
+
source = f.read()
|
|
178
|
+
tree = ast.parse(source)
|
|
179
|
+
for node in ast.walk(tree):
|
|
180
|
+
if isinstance(node, ast.Call):
|
|
181
|
+
func = node.func
|
|
182
|
+
if isinstance(func, ast.Name) and func.id == function_name:
|
|
183
|
+
return True
|
|
184
|
+
if isinstance(func, ast.Attribute) and func.attr == function_name:
|
|
185
|
+
return True
|
|
186
|
+
except (SyntaxError, OSError):
|
|
187
|
+
continue
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _function_used_regex(function_name, files):
|
|
192
|
+
"""Legacy regex-based fallback used when no compiled artifact is available."""
|
|
193
|
+
regex = rf'(^|[^[:alnum:]_]){function_name}[[:space:]]*\('
|
|
107
194
|
cmd = (
|
|
108
195
|
"sed -E "
|
|
109
196
|
"'"
|
|
110
|
-
"s://.*$::g; "
|
|
111
|
-
"s:#.*$::g; "
|
|
197
|
+
"s://.*$::g; "
|
|
198
|
+
"s:#.*$::g; "
|
|
112
199
|
":a; /\\/\\*/{N; s:/\\*.*?\\*/::g; ba}"
|
|
113
200
|
"' "
|
|
114
201
|
+ " ".join(files)
|
|
115
202
|
+ f" | grep -E '{regex}'"
|
|
116
203
|
)
|
|
117
|
-
|
|
118
|
-
function_popen = subprocess.Popen(
|
|
204
|
+
result = subprocess.run(
|
|
119
205
|
["bash", "-c", cmd],
|
|
120
206
|
stdout=subprocess.PIPE,
|
|
121
207
|
stderr=subprocess.PIPE,
|
|
122
208
|
)
|
|
209
|
+
return result.returncode == 0
|
|
123
210
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
211
|
+
|
|
212
|
+
def _is_function_used(function_name, files):
|
|
213
|
+
"""Detect whether function_name is used in the given files.
|
|
214
|
+
|
|
215
|
+
Dispatches to the appropriate tool based on file language:
|
|
216
|
+
- C/C++: objdump on compiled artifact (falls back to regex if none found)
|
|
217
|
+
- Java: javap on .class file (falls back to regex if none found)
|
|
218
|
+
- Python: ast module on source
|
|
219
|
+
- Other: regex on source
|
|
220
|
+
"""
|
|
221
|
+
lang = _detect_language(files)
|
|
222
|
+
if lang == 'python':
|
|
223
|
+
return _function_used_in_python(function_name, files)
|
|
224
|
+
if lang == 'java':
|
|
225
|
+
result = _function_used_in_java(function_name, files)
|
|
226
|
+
if result is None:
|
|
227
|
+
return _function_used_regex(function_name, files)
|
|
228
|
+
return result
|
|
229
|
+
if lang == 'c_cpp':
|
|
230
|
+
result = _function_used_in_c_cpp(function_name, files)
|
|
231
|
+
if result is None:
|
|
232
|
+
return _function_used_regex(function_name, files)
|
|
233
|
+
return result
|
|
234
|
+
return _function_used_regex(function_name, files)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_function(args):
|
|
238
|
+
"""Will be followed by a function name and a list of files to check to ensure that the function
|
|
239
|
+
is used by one of those files.
|
|
240
|
+
|
|
241
|
+
Arguments:
|
|
242
|
+
function_name: the function name to check files for usage of
|
|
243
|
+
*files: the files to check for the function name
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
None
|
|
247
|
+
"""
|
|
248
|
+
check_test()
|
|
249
|
+
args = args.split()
|
|
250
|
+
function_name = args[0]
|
|
251
|
+
files = args[1:]
|
|
252
|
+
|
|
253
|
+
if _is_function_used(function_name, files):
|
|
128
254
|
print(f"Used {function_name} PASSED")
|
|
255
|
+
else:
|
|
256
|
+
print(f"Not using {function_name} FAILED")
|
|
129
257
|
|
|
130
258
|
def check_object(args):
|
|
131
259
|
"""Will be followed by an object and a list of files to ensure that the function is
|
|
@@ -228,7 +356,7 @@ def check_not_function(args):
|
|
|
228
356
|
is not used by any of those files.
|
|
229
357
|
|
|
230
358
|
Arguments:
|
|
231
|
-
function_name: the
|
|
359
|
+
function_name: the function name to check files for usage of
|
|
232
360
|
*files: the files to check for the function name
|
|
233
361
|
|
|
234
362
|
Returns:
|
|
@@ -239,18 +367,23 @@ def check_not_function(args):
|
|
|
239
367
|
function_name = args[0]
|
|
240
368
|
files = args[1:]
|
|
241
369
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
["grep", f"[^[:alpha:]]{function_name}[[:space:]]*("] + files,
|
|
245
|
-
stdout=subprocess.PIPE,
|
|
246
|
-
stderr=subprocess.PIPE,
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
function_popen.communicate()
|
|
250
|
-
if function_popen.returncode:
|
|
251
|
-
print(f"used{function_name} PASSED")
|
|
370
|
+
if _is_function_used(function_name, files):
|
|
371
|
+
print(f"Used {function_name} FAILED")
|
|
252
372
|
else:
|
|
253
|
-
print(f"
|
|
373
|
+
print(f"Not using {function_name} PASSED")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def print_label(text):
|
|
377
|
+
"""Print a label/message to stdout.
|
|
378
|
+
|
|
379
|
+
Arguments:
|
|
380
|
+
text: the text to print
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
None
|
|
384
|
+
"""
|
|
385
|
+
check_test()
|
|
386
|
+
print(text)
|
|
254
387
|
|
|
255
388
|
|
|
256
389
|
def run_command(command):
|
|
@@ -511,6 +644,19 @@ def timeout(timeout_sec):
|
|
|
511
644
|
timeout_val = float(timeout_sec)
|
|
512
645
|
|
|
513
646
|
|
|
647
|
+
def output_length(length):
|
|
648
|
+
"""Specifies the maximum number of bytes of diff output to render. Defaults to 4096.
|
|
649
|
+
|
|
650
|
+
Arguments:
|
|
651
|
+
length: maximum output length in bytes
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
None
|
|
655
|
+
"""
|
|
656
|
+
global output_length_limit
|
|
657
|
+
output_length_limit = int(length)
|
|
658
|
+
|
|
659
|
+
|
|
514
660
|
def exit_code(test_case_exit_code):
|
|
515
661
|
"""Specifies the expected exit code for a test case. Defaults to zero.
|
|
516
662
|
|
|
@@ -578,6 +724,7 @@ tag_func_map = {
|
|
|
578
724
|
"CO" : check_object,
|
|
579
725
|
"CC": check_container,
|
|
580
726
|
"NCF": check_not_function,
|
|
727
|
+
"PRINT": print_label,
|
|
581
728
|
"CMD": run_command,
|
|
582
729
|
"TCMD": run_command_noerror,
|
|
583
730
|
"CMP": compare,
|
|
@@ -593,6 +740,7 @@ tag_func_map = {
|
|
|
593
740
|
"EB": check_error_bare,
|
|
594
741
|
"HINT": hint,
|
|
595
742
|
"TO": timeout,
|
|
743
|
+
"OLEN": output_length,
|
|
596
744
|
"X": exit_code,
|
|
597
745
|
"SS": start_server,
|
|
598
746
|
}
|
|
@@ -774,6 +922,93 @@ def parse_diff(diff_lines: list[str], testing_dir: str):
|
|
|
774
922
|
outfile.write(line)
|
|
775
923
|
|
|
776
924
|
|
|
925
|
+
def _render_diff_output(raw_bytes: bytes) -> str:
|
|
926
|
+
"""Render raw bytes for diff output, making unprintable characters visible.
|
|
927
|
+
|
|
928
|
+
Replaces the shell pipeline `cat -te | head -22` with pure Python.
|
|
929
|
+
"""
|
|
930
|
+
result = []
|
|
931
|
+
i = 0
|
|
932
|
+
n = len(raw_bytes)
|
|
933
|
+
|
|
934
|
+
while i < n:
|
|
935
|
+
b = raw_bytes[i]
|
|
936
|
+
|
|
937
|
+
if b == 0x0A: # newline
|
|
938
|
+
result.append("$\n")
|
|
939
|
+
i += 1
|
|
940
|
+
elif b == 0x20 or (0x21 <= b <= 0x7E): # space or printable ASCII
|
|
941
|
+
result.append(chr(b))
|
|
942
|
+
i += 1
|
|
943
|
+
elif b <= 0x1F: # control chars (except newline handled above)
|
|
944
|
+
result.append("^" + chr(b + 0x40))
|
|
945
|
+
i += 1
|
|
946
|
+
elif b == 0x7F: # DEL
|
|
947
|
+
result.append("^?")
|
|
948
|
+
i += 1
|
|
949
|
+
elif b >= 0x80:
|
|
950
|
+
# Try to decode as UTF-8 multi-byte sequence
|
|
951
|
+
seq_len = 0
|
|
952
|
+
if (b & 0xE0) == 0xC0:
|
|
953
|
+
seq_len = 2
|
|
954
|
+
elif (b & 0xF0) == 0xE0:
|
|
955
|
+
seq_len = 3
|
|
956
|
+
elif (b & 0xF8) == 0xF0:
|
|
957
|
+
seq_len = 4
|
|
958
|
+
|
|
959
|
+
decoded_char = None
|
|
960
|
+
if seq_len >= 2 and i + seq_len <= n:
|
|
961
|
+
try:
|
|
962
|
+
decoded_char = raw_bytes[i:i + seq_len].decode("utf-8")
|
|
963
|
+
except UnicodeDecodeError:
|
|
964
|
+
pass
|
|
965
|
+
|
|
966
|
+
if decoded_char and len(decoded_char) == 1 and decoded_char.isprintable():
|
|
967
|
+
result.append(decoded_char)
|
|
968
|
+
i += seq_len
|
|
969
|
+
elif decoded_char and len(decoded_char) == 1:
|
|
970
|
+
# Valid UTF-8 but non-printable: render each byte as \xCC
|
|
971
|
+
for j in range(seq_len):
|
|
972
|
+
result.append(f"\\x{raw_bytes[i + j]:02X}")
|
|
973
|
+
i += seq_len
|
|
974
|
+
else:
|
|
975
|
+
# Invalid UTF-8 byte
|
|
976
|
+
result.append(f"\\x{b:02X}")
|
|
977
|
+
i += 1
|
|
978
|
+
else:
|
|
979
|
+
result.append(chr(b))
|
|
980
|
+
i += 1
|
|
981
|
+
|
|
982
|
+
output = "".join(result)
|
|
983
|
+
|
|
984
|
+
# If output doesn't end with \n, append $ at the very end
|
|
985
|
+
if output and not output.endswith("\n"):
|
|
986
|
+
output += "$"
|
|
987
|
+
|
|
988
|
+
return output
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _find_touched_files(pre_run_timestamp):
|
|
992
|
+
"""Find files touched (modified or accessed) since pre_run_timestamp.
|
|
993
|
+
|
|
994
|
+
Recursively scans the current working directory, ignoring TESTING_DIR.
|
|
995
|
+
"""
|
|
996
|
+
touched = []
|
|
997
|
+
for dirpath, dirnames, filenames in os.walk("."):
|
|
998
|
+
# Prune TESTING_DIR from traversal
|
|
999
|
+
if TESTING_DIR in dirnames:
|
|
1000
|
+
dirnames.remove(TESTING_DIR)
|
|
1001
|
+
for filename in filenames:
|
|
1002
|
+
filepath = os.path.join(dirpath, filename)
|
|
1003
|
+
try:
|
|
1004
|
+
st = os.stat(filepath)
|
|
1005
|
+
if st.st_mtime > pre_run_timestamp or st.st_atime > pre_run_timestamp:
|
|
1006
|
+
touched.append(filepath)
|
|
1007
|
+
except OSError:
|
|
1008
|
+
continue
|
|
1009
|
+
return touched
|
|
1010
|
+
|
|
1011
|
+
|
|
777
1012
|
def check_test():
|
|
778
1013
|
global test_args
|
|
779
1014
|
if test_args == "":
|
|
@@ -782,6 +1017,9 @@ def check_test():
|
|
|
782
1017
|
print(f"Test case {test_case_count} of {test_case_total}")
|
|
783
1018
|
passed = True
|
|
784
1019
|
|
|
1020
|
+
# Record timestamp before running student code for file tracking
|
|
1021
|
+
pre_run_timestamp = time.time()
|
|
1022
|
+
|
|
785
1023
|
with open(get_testing_path("fileinput"), "rb") as fileinput, open(
|
|
786
1024
|
get_testing_path("youroutput"), "w"
|
|
787
1025
|
) as youroutput, open(get_testing_path("yourerror"), "w") as yourerror:
|
|
@@ -797,27 +1035,28 @@ def check_test():
|
|
|
797
1035
|
print(f"Took more than {timeout_val} seconds to run. FAIL")
|
|
798
1036
|
passed = False
|
|
799
1037
|
|
|
1038
|
+
# Find files touched by student's program
|
|
1039
|
+
touched_files = _find_touched_files(pre_run_timestamp)
|
|
1040
|
+
|
|
800
1041
|
# Difflog handling
|
|
801
1042
|
with open(get_testing_path("difflog"), "w") as outfile:
|
|
802
1043
|
diff_popen = subprocess.Popen(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
stdout=
|
|
806
|
-
stderr=outfile,
|
|
807
|
-
text=True,
|
|
1044
|
+
["diff", "-U1", "-a",
|
|
1045
|
+
f"./{TESTING_DIR}/youroutput", f"./{TESTING_DIR}/expectedoutput"],
|
|
1046
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
808
1047
|
)
|
|
809
|
-
diff_popen.communicate()
|
|
1048
|
+
raw_output, _ = diff_popen.communicate()
|
|
1049
|
+
outfile.write(_render_diff_output(raw_output[:output_length_limit]))
|
|
810
1050
|
|
|
811
1051
|
# Append to difflog second time around
|
|
812
1052
|
with open(get_testing_path("difflog"), "a") as outfile:
|
|
813
1053
|
diff_popen = subprocess.Popen(
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
stdout=
|
|
817
|
-
stderr=outfile,
|
|
818
|
-
text=True,
|
|
1054
|
+
["diff", "-U1", "-a",
|
|
1055
|
+
f"./{TESTING_DIR}/yourerror", f"./{TESTING_DIR}/expectederror"],
|
|
1056
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
819
1057
|
)
|
|
820
|
-
diff_popen.communicate()
|
|
1058
|
+
raw_output, _ = diff_popen.communicate()
|
|
1059
|
+
outfile.write(_render_diff_output(raw_output[:output_length_limit]))
|
|
821
1060
|
|
|
822
1061
|
# Now read all the lines to accumulate both diffs
|
|
823
1062
|
with open(get_testing_path("difflog"), "r") as infile:
|
|
@@ -872,6 +1111,33 @@ def check_test():
|
|
|
872
1111
|
# Print entire file
|
|
873
1112
|
print("".join(file_lines))
|
|
874
1113
|
|
|
1114
|
+
# Show diffs for files touched by student's program
|
|
1115
|
+
if touched_files and cmps:
|
|
1116
|
+
expected_map = {}
|
|
1117
|
+
for file_pair in cmps:
|
|
1118
|
+
if len(file_pair) >= 2:
|
|
1119
|
+
expected_map[os.path.normpath(file_pair[0])] = file_pair[1]
|
|
1120
|
+
|
|
1121
|
+
for filepath in touched_files:
|
|
1122
|
+
normalized = os.path.normpath(filepath)
|
|
1123
|
+
if normalized in expected_map:
|
|
1124
|
+
expected_file = expected_map[normalized]
|
|
1125
|
+
if os.path.exists(expected_file):
|
|
1126
|
+
diff_proc = subprocess.Popen(
|
|
1127
|
+
["diff", "-U1", "-a", filepath, expected_file],
|
|
1128
|
+
stdout=subprocess.PIPE,
|
|
1129
|
+
stderr=subprocess.PIPE,
|
|
1130
|
+
text=True,
|
|
1131
|
+
)
|
|
1132
|
+
diff_output, _ = diff_proc.communicate()
|
|
1133
|
+
if diff_output:
|
|
1134
|
+
print(f" File output diff ({os.path.basename(filepath)}):")
|
|
1135
|
+
lines = diff_output.splitlines()
|
|
1136
|
+
for line in lines[:22]:
|
|
1137
|
+
print(f" {line}")
|
|
1138
|
+
if len(lines) > 22:
|
|
1139
|
+
print(f" ... ({len(lines) - 22} more lines)")
|
|
1140
|
+
|
|
875
1141
|
cleanup()
|
|
876
1142
|
|
|
877
1143
|
# Exit program after failed test case
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import zipfile
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _read_referenced_file(filename, zip_archives):
|
|
9
|
+
"""Read a file referenced by IF/OF/EF from zip archives.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
filename: the filename from the tag
|
|
13
|
+
zip_archives: list of opened ZipFile objects
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
file contents as string
|
|
17
|
+
"""
|
|
18
|
+
for zf in zip_archives:
|
|
19
|
+
try:
|
|
20
|
+
return zf.read(filename).decode("utf-8")
|
|
21
|
+
except KeyError:
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
raise FileNotFoundError(
|
|
25
|
+
f"Referenced file '{filename}' not found in zip archives"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_codeval_tests(codeval_file):
|
|
30
|
+
"""Parse a codeval file and extract test cases.
|
|
31
|
+
|
|
32
|
+
Returns a list of dicts with keys:
|
|
33
|
+
num, command, input, output, error, exit_code, hidden
|
|
34
|
+
"""
|
|
35
|
+
codeval_dir = os.path.dirname(os.path.abspath(codeval_file))
|
|
36
|
+
|
|
37
|
+
with open(codeval_file, "r") as f:
|
|
38
|
+
lines = f.readlines()
|
|
39
|
+
|
|
40
|
+
# First pass: collect Z tags to open zip archives
|
|
41
|
+
tag_pattern = r"([A-Z_]+) (.*)"
|
|
42
|
+
zip_paths = []
|
|
43
|
+
in_crt_hw = False
|
|
44
|
+
for line in lines:
|
|
45
|
+
stripped = line.strip()
|
|
46
|
+
if stripped.startswith("CRT_HW"):
|
|
47
|
+
in_crt_hw = not in_crt_hw
|
|
48
|
+
continue
|
|
49
|
+
if in_crt_hw:
|
|
50
|
+
continue
|
|
51
|
+
m = re.match(tag_pattern, line)
|
|
52
|
+
if m and m.group(1) == "Z":
|
|
53
|
+
zip_paths.append(os.path.join(codeval_dir, m.group(2).strip()))
|
|
54
|
+
|
|
55
|
+
# Open zip archives for the duration of parsing
|
|
56
|
+
zip_archives = []
|
|
57
|
+
try:
|
|
58
|
+
for zp in zip_paths:
|
|
59
|
+
if os.path.exists(zp):
|
|
60
|
+
zip_archives.append(zipfile.ZipFile(zp, "r"))
|
|
61
|
+
|
|
62
|
+
tests = _parse_codeval_lines(lines, zip_archives)
|
|
63
|
+
finally:
|
|
64
|
+
for zf in zip_archives:
|
|
65
|
+
zf.close()
|
|
66
|
+
|
|
67
|
+
return tests
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_codeval_lines(lines, zip_archives):
|
|
71
|
+
"""Parse codeval lines into test case dicts."""
|
|
72
|
+
tag_pattern = r"([A-Z_]+) (.*)"
|
|
73
|
+
in_crt_hw_block = False
|
|
74
|
+
tests = []
|
|
75
|
+
current = None
|
|
76
|
+
test_num = 0
|
|
77
|
+
|
|
78
|
+
def finalize():
|
|
79
|
+
if current is not None:
|
|
80
|
+
tests.append(current)
|
|
81
|
+
|
|
82
|
+
for line in lines:
|
|
83
|
+
stripped = line.strip()
|
|
84
|
+
if not stripped or stripped.startswith("#"):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if stripped.startswith("CRT_HW"):
|
|
88
|
+
in_crt_hw_block = not in_crt_hw_block
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
if in_crt_hw_block:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
tag_match = re.match(tag_pattern, line)
|
|
95
|
+
if not tag_match:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
tag = tag_match.group(1)
|
|
99
|
+
args = tag_match.group(2)
|
|
100
|
+
|
|
101
|
+
if tag in ("T", "HT"):
|
|
102
|
+
finalize()
|
|
103
|
+
test_num += 1
|
|
104
|
+
current = {
|
|
105
|
+
"num": test_num,
|
|
106
|
+
"command": args,
|
|
107
|
+
"input": "",
|
|
108
|
+
"output": "",
|
|
109
|
+
"error": "",
|
|
110
|
+
"exit_code": -1,
|
|
111
|
+
"hidden": tag == "HT",
|
|
112
|
+
}
|
|
113
|
+
elif tag == "TCMD":
|
|
114
|
+
finalize()
|
|
115
|
+
test_num += 1
|
|
116
|
+
current = {
|
|
117
|
+
"num": test_num,
|
|
118
|
+
"command": args,
|
|
119
|
+
"input": "",
|
|
120
|
+
"output": "",
|
|
121
|
+
"error": "",
|
|
122
|
+
"exit_code": -1,
|
|
123
|
+
"hidden": False,
|
|
124
|
+
}
|
|
125
|
+
elif current is not None:
|
|
126
|
+
if tag == "I":
|
|
127
|
+
current["input"] += args + "\n"
|
|
128
|
+
elif tag == "IB":
|
|
129
|
+
current["input"] += args
|
|
130
|
+
elif tag == "IF":
|
|
131
|
+
current["input"] += _read_referenced_file(
|
|
132
|
+
args.strip(), zip_archives
|
|
133
|
+
)
|
|
134
|
+
elif tag == "O":
|
|
135
|
+
current["output"] += args + "\n"
|
|
136
|
+
elif tag == "OB":
|
|
137
|
+
current["output"] += args
|
|
138
|
+
elif tag == "OF":
|
|
139
|
+
current["output"] += _read_referenced_file(
|
|
140
|
+
args.strip(), zip_archives
|
|
141
|
+
)
|
|
142
|
+
elif tag == "E":
|
|
143
|
+
current["error"] += args + "\n"
|
|
144
|
+
elif tag == "EB":
|
|
145
|
+
current["error"] += args
|
|
146
|
+
elif tag == "X":
|
|
147
|
+
current["exit_code"] = int(args.strip())
|
|
148
|
+
|
|
149
|
+
finalize()
|
|
150
|
+
return tests
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@click.command()
|
|
154
|
+
@click.argument("codeval_file", type=click.Path(exists=True))
|
|
155
|
+
@click.option("--include-hidden", is_flag=True, help="Include hidden test cases")
|
|
156
|
+
@click.option("--output", "-o", default=None, help="Output zip file path")
|
|
157
|
+
def export_tests(codeval_file, include_hidden, output):
|
|
158
|
+
"""Export test cases from a codeval file into a zip archive.
|
|
159
|
+
|
|
160
|
+
Creates a zip with in.X, out.X, err.X files for each test case
|
|
161
|
+
and a TESTS.md summary.
|
|
162
|
+
"""
|
|
163
|
+
tests = parse_codeval_tests(codeval_file)
|
|
164
|
+
|
|
165
|
+
if output is None:
|
|
166
|
+
basename = os.path.splitext(os.path.basename(codeval_file))[0]
|
|
167
|
+
output = f"{basename}_tests.zip"
|
|
168
|
+
|
|
169
|
+
filtered = [t for t in tests if include_hidden or not t["hidden"]]
|
|
170
|
+
|
|
171
|
+
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
172
|
+
tests_md = ""
|
|
173
|
+
for test in filtered:
|
|
174
|
+
num = test["num"]
|
|
175
|
+
zf.writestr(f"in.{num}", test["input"])
|
|
176
|
+
zf.writestr(f"out.{num}", test["output"])
|
|
177
|
+
zf.writestr(f"err.{num}", test["error"])
|
|
178
|
+
|
|
179
|
+
tests_md += f"# Test {num}\n"
|
|
180
|
+
tests_md += f"command: `{test['command']}`\n"
|
|
181
|
+
if test["exit_code"] != -1:
|
|
182
|
+
tests_md += f"expected exit code: {test['exit_code']}\n"
|
|
183
|
+
tests_md += "\n"
|
|
184
|
+
|
|
185
|
+
zf.writestr("TESTS.md", tests_md)
|
|
186
|
+
|
|
187
|
+
click.echo(f"Exported {len(filtered)} test(s) to {output}")
|
|
@@ -5,6 +5,7 @@ import subprocess
|
|
|
5
5
|
import time
|
|
6
6
|
from configparser import ConfigParser
|
|
7
7
|
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
8
9
|
from functools import cache
|
|
9
10
|
from tempfile import TemporaryDirectory
|
|
10
11
|
from zipfile import ZipFile
|
|
@@ -61,10 +62,160 @@ def get_github_repo_url(course, user_id, config_parser, github_field="github"):
|
|
|
61
62
|
return None
|
|
62
63
|
|
|
63
64
|
|
|
65
|
+
def _get_canvas_config():
|
|
66
|
+
"""Get Canvas URL and token from config."""
|
|
67
|
+
parser = ConfigParser()
|
|
68
|
+
config_file = click.get_app_dir("codeval.ini")
|
|
69
|
+
parser.read(config_file)
|
|
70
|
+
return parser['SERVER']['url'].rstrip('/'), parser['SERVER']['token']
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_html_file(dirpath):
|
|
74
|
+
import html as _html
|
|
75
|
+
|
|
76
|
+
assignment_name = student_name = attempt_no = last_submitted = ""
|
|
77
|
+
with open(f"{dirpath}/metadata.txt", "r") as f:
|
|
78
|
+
for line in f:
|
|
79
|
+
if line.startswith("assignment="):
|
|
80
|
+
assignment_name = line.strip().split("=", 1)[1]
|
|
81
|
+
elif line.startswith("name="):
|
|
82
|
+
student_name = line.strip().split("=", 1)[1]
|
|
83
|
+
elif line.startswith("attempt="):
|
|
84
|
+
attempt_no = line.strip().split("=", 1)[1]
|
|
85
|
+
elif line.startswith("date="):
|
|
86
|
+
last_submitted = line.strip().split("=", 1)[1]
|
|
87
|
+
|
|
88
|
+
with open(f"{dirpath}/comments.txt", "r") as f:
|
|
89
|
+
comments_content = f.read()
|
|
90
|
+
|
|
91
|
+
# Color each line based on PASS/FAIL/error keywords
|
|
92
|
+
def colorize_lines(text):
|
|
93
|
+
lines_html = []
|
|
94
|
+
for line in text.split('\n'):
|
|
95
|
+
escaped = _html.escape(line)
|
|
96
|
+
if line.startswith('PASS'):
|
|
97
|
+
lines_html.append(f'<span class="pass">{escaped}</span>')
|
|
98
|
+
elif line.startswith('FAIL'):
|
|
99
|
+
lines_html.append(f'<span class="fail">{escaped}</span>')
|
|
100
|
+
elif any(line.startswith(k) for k in ('ERROR', 'TOOK LONGER', 'FAILED')):
|
|
101
|
+
lines_html.append(f'<span class="error">{escaped}</span>')
|
|
102
|
+
else:
|
|
103
|
+
lines_html.append(escaped)
|
|
104
|
+
return '\n'.join(lines_html)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
dt = datetime.strptime(last_submitted, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
|
|
108
|
+
dt = dt.astimezone(ZoneInfo('America/Los_Angeles'))
|
|
109
|
+
last_submitted = dt.strftime('%B %d, %Y at %I:%M %p %Z')
|
|
110
|
+
except (ValueError, TypeError):
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
pass_count = sum(1 for l in comments_content.split('\n') if l.startswith('PASS'))
|
|
114
|
+
fail_count = sum(1 for l in comments_content.split('\n') if l.startswith('FAIL'))
|
|
115
|
+
|
|
116
|
+
template_path = os.path.join(os.path.dirname(__file__), 'test_template.html')
|
|
117
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
118
|
+
html_content = f.read()
|
|
119
|
+
|
|
120
|
+
html_content = (html_content
|
|
121
|
+
.replace('{{ASSIGNMENT_NAME}}', _html.escape(assignment_name))
|
|
122
|
+
.replace('{{STUDENT_NAME}}', _html.escape(student_name))
|
|
123
|
+
.replace('{{SUBMITTED}}', _html.escape(last_submitted))
|
|
124
|
+
.replace('{{ATTEMPT}}', _html.escape(attempt_no))
|
|
125
|
+
.replace('{{PASS_COUNT}}', str(pass_count))
|
|
126
|
+
.replace('{{FAIL_COUNT}}', str(fail_count))
|
|
127
|
+
.replace('{{OUTPUT}}', colorize_lines(comments_content))
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
with open(f"{dirpath}/results.html", "w", encoding="utf-8") as dst:
|
|
131
|
+
dst.write(html_content)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def upload_file_for_comment(canvas, course_id, assignment_id, user_id, file_path):
|
|
135
|
+
"""
|
|
136
|
+
Upload a file to attach to a submission comment.
|
|
137
|
+
Returns the file_id to use when creating/editing the comment.
|
|
138
|
+
"""
|
|
139
|
+
canvas_url, canvas_token = _get_canvas_config()
|
|
140
|
+
auth = {'Authorization': f'Bearer {canvas_token}'}
|
|
141
|
+
|
|
142
|
+
file_name = os.path.basename(file_path)
|
|
143
|
+
file_size = os.path.getsize(file_path)
|
|
144
|
+
|
|
145
|
+
# Step 1: Request upload URL
|
|
146
|
+
r1 = requests.post(
|
|
147
|
+
f'{canvas_url}/api/v1/courses/{course_id}/assignments/{assignment_id}/submissions/{user_id}/comments/files',
|
|
148
|
+
headers=auth,
|
|
149
|
+
data={'name': file_name, 'size': file_size, 'content_type': 'text/html'}
|
|
150
|
+
)
|
|
151
|
+
r1.raise_for_status()
|
|
152
|
+
upload_info = r1.json()
|
|
153
|
+
|
|
154
|
+
# Step 2: Upload file to storage
|
|
155
|
+
with open(file_path, 'rb') as f:
|
|
156
|
+
r2 = requests.post(
|
|
157
|
+
upload_info['upload_url'],
|
|
158
|
+
data=upload_info['upload_params'],
|
|
159
|
+
files={'file': (file_name, f, 'text/html')},
|
|
160
|
+
allow_redirects=False
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Step 3: Confirm upload
|
|
164
|
+
if r2.status_code in (301, 302, 303):
|
|
165
|
+
r3 = requests.get(r2.headers['Location'], headers=auth)
|
|
166
|
+
r3.raise_for_status()
|
|
167
|
+
return r3.json()['id']
|
|
168
|
+
else:
|
|
169
|
+
r2.raise_for_status()
|
|
170
|
+
return r2.json()['id']
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def delete_submission_comment(canvas, course_id, assignment_id, user_id, comment_id):
|
|
174
|
+
"""Delete a submission comment."""
|
|
175
|
+
canvas_url, canvas_token = _get_canvas_config()
|
|
176
|
+
resp = requests.delete(
|
|
177
|
+
f'{canvas_url}/api/v1/courses/{course_id}/assignments/{assignment_id}/submissions/{user_id}/comments/{comment_id}',
|
|
178
|
+
headers={'Authorization': f'Bearer {canvas_token}'}
|
|
179
|
+
)
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_substitutions_file(filepath):
|
|
184
|
+
"""Parse a substitutions file where each line has the form /pattern/replacement/.
|
|
185
|
+
|
|
186
|
+
The first character of each line defines the delimiter. The delimiter must appear
|
|
187
|
+
exactly once in-between (separating pattern from replacement) and once at the end.
|
|
188
|
+
Returns a list of (pattern, replacement) tuples.
|
|
189
|
+
Raises click.ClickException on parse errors.
|
|
190
|
+
"""
|
|
191
|
+
substitutions = []
|
|
192
|
+
with open(filepath, "r") as fd:
|
|
193
|
+
for lineno, line in enumerate(fd, start=1):
|
|
194
|
+
line = line.rstrip("\n")
|
|
195
|
+
if not line:
|
|
196
|
+
continue
|
|
197
|
+
delim = line[0]
|
|
198
|
+
parts = line[1:].split(delim)
|
|
199
|
+
if len(parts) != 3 or parts[2] != "":
|
|
200
|
+
raise click.ClickException(
|
|
201
|
+
f"{filepath}:{lineno}: invalid substitution line: {line}"
|
|
202
|
+
)
|
|
203
|
+
substitutions.append((parts[0], parts[1]))
|
|
204
|
+
return substitutions
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _apply_substitutions(comment, substitutions):
|
|
208
|
+
"""Apply a list of (pattern, replacement) substitutions to comment text."""
|
|
209
|
+
for pattern, replacement in substitutions:
|
|
210
|
+
comment = comment.replace(pattern, replacement)
|
|
211
|
+
return comment
|
|
212
|
+
|
|
213
|
+
|
|
64
214
|
@click.command()
|
|
65
215
|
@click.argument("submissions_dir", metavar="SUBMISSIONS_DIR")
|
|
66
216
|
@click.option("--codeval-prefix", help="prefix for codeval comments", default="codeval: ", show_default=True)
|
|
67
|
-
|
|
217
|
+
@click.option("--delete", is_flag=True, help="deletes the previous comment before uploading")
|
|
218
|
+
def upload_submission_comments(submissions_dir, codeval_prefix, delete):
|
|
68
219
|
"""
|
|
69
220
|
Upload comments for submissions from a directory.
|
|
70
221
|
|
|
@@ -75,31 +226,49 @@ def upload_submission_comments(submissions_dir, codeval_prefix):
|
|
|
75
226
|
if the file comments.txt.sent exists, the comment has already been uploaded and will be skipped.
|
|
76
227
|
"""
|
|
77
228
|
(canvas, user) = connect_to_canvas()
|
|
78
|
-
clean_submissions_dir = submissions_dir.rstrip('/')
|
|
229
|
+
clean_submissions_dir = submissions_dir.rstrip('/').replace('\\', '/')
|
|
79
230
|
for dirpath, dirnames, filenames in os.walk(clean_submissions_dir):
|
|
80
|
-
|
|
231
|
+
dirpath = dirpath.replace('\\', '/')
|
|
232
|
+
match = re.match(fr'^{re.escape(clean_submissions_dir)}/([^/]+)/([^/]+)/([^/]+)$', dirpath)
|
|
81
233
|
if match:
|
|
82
234
|
course_name = match.group(1)
|
|
83
235
|
assignment_name = match.group(2)
|
|
84
236
|
student_id = match.group(3)
|
|
85
|
-
if "comments.txt" in filenames:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
237
|
+
if "comments.txt.sent" in filenames:
|
|
238
|
+
info(f"skipping already uploaded comments for {student_id} in {course_name}: {assignment_name}")
|
|
239
|
+
elif "comments.txt" in filenames:
|
|
240
|
+
info(f"uploading comments for {student_id} in {course_name}: {assignment_name}")
|
|
241
|
+
course = get_course(canvas, course_name)
|
|
242
|
+
assignment = get_assignment(course, assignment_name)
|
|
243
|
+
submission = get_submissions_by_id(assignment).get(student_id)
|
|
244
|
+
if submission:
|
|
245
|
+
write_html_file(dirpath)
|
|
246
|
+
file_id = upload_file_for_comment(canvas, course.id, assignment.id, student_id, f"{dirpath}/results.html")
|
|
92
247
|
with open(f"{dirpath}/comments.txt", "r") as fd:
|
|
93
248
|
comment = fd.read(4096)
|
|
94
|
-
# nulls seem to be very problematic for canvas
|
|
95
249
|
comment = comment.replace("\0", "\\0").strip().replace("<", "<")
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
250
|
+
subs_file = f"{dirpath}/SUBSTITUTIONS.txt"
|
|
251
|
+
if os.path.isfile(subs_file):
|
|
252
|
+
substitutions = _parse_substitutions_file(subs_file)
|
|
253
|
+
comment = _apply_substitutions(comment, substitutions)
|
|
254
|
+
if delete:
|
|
255
|
+
all_comments = sorted(submission.submission_comments, key=lambda c: c['created_at'])
|
|
256
|
+
if all_comments:
|
|
257
|
+
last_comment_id = all_comments[-1]["id"]
|
|
258
|
+
delete_submission_comment(canvas, course.id, assignment.id, student_id, last_comment_id)
|
|
259
|
+
canvas_url, canvas_token = _get_canvas_config()
|
|
260
|
+
requests.put(
|
|
261
|
+
f'{canvas_url}/api/v1/courses/{course.id}/assignments/{assignment.id}/submissions/{student_id}',
|
|
262
|
+
headers={'Authorization': f'Bearer {canvas_token}'},
|
|
263
|
+
data={
|
|
264
|
+
'comment[text_comment]': f'{codeval_prefix.rstrip()}\n<pre>{comment}</pre>',
|
|
265
|
+
'comment[file_ids][]': file_id,
|
|
266
|
+
}
|
|
267
|
+
).raise_for_status()
|
|
268
|
+
else:
|
|
269
|
+
warn(f"no submission found for {student_id} in {course_name}: {assignment_name}")
|
|
270
|
+
with open(f"{dirpath}/comments.txt.sent", "w") as fd:
|
|
271
|
+
fd.write(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
|
|
103
272
|
|
|
104
273
|
|
|
105
274
|
@click.command()
|
|
@@ -178,6 +347,9 @@ def evaluate_submissions(codeval_dir, submissions_dir):
|
|
|
178
347
|
if not move_to_next_submission:
|
|
179
348
|
for zf_name in zip_files:
|
|
180
349
|
with ZipFile(os.path.join(codeval_dir, zf_name)) as zf:
|
|
350
|
+
if "SUBSTITUTIONS.txt" in zf.namelist():
|
|
351
|
+
with open(os.path.join(dirpath, "SUBSTITUTIONS.txt"), "wb") as out_f:
|
|
352
|
+
out_f.write(zf.read("SUBSTITUTIONS.txt"))
|
|
181
353
|
for f in zf.infolist():
|
|
182
354
|
dest_dir = os.path.join(submission_dir, assignment_working_dir)
|
|
183
355
|
zf.extract(f, dest_dir)
|
|
@@ -334,7 +506,7 @@ def _download_assignment_submissions(canvas, course, assignment, target_dir, inc
|
|
|
334
506
|
continue
|
|
335
507
|
|
|
336
508
|
submission_comments = [c['created_at'] for c in submission.submission_comments if
|
|
337
|
-
'comment' in c and c['comment'].startswith(codeval_prefix)]
|
|
509
|
+
'comment' in c and c['comment'].startswith(codeval_prefix.rstrip())]
|
|
338
510
|
submission_comments.sort()
|
|
339
511
|
if submission_comments:
|
|
340
512
|
last_comment_date = submission_comments[-1]
|
|
@@ -397,7 +569,7 @@ github_repo={github_repo or ''}""", file=fd)
|
|
|
397
569
|
@cache
|
|
398
570
|
def get_submissions_by_id(assignment):
|
|
399
571
|
submissions_by_id = {}
|
|
400
|
-
for submission in assignment.get_submissions(include=["user"]):
|
|
572
|
+
for submission in assignment.get_submissions(include=["submission_comments", "user"]):
|
|
401
573
|
student_id = str(submission.user_id)
|
|
402
574
|
submissions_by_id[student_id] = submission
|
|
403
575
|
return submissions_by_id
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>CodEval Results – {{ASSIGNMENT_NAME}}</title>
|
|
6
|
+
<style>
|
|
7
|
+
body {
|
|
8
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
9
|
+
background: #f0f2f5;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 24px;
|
|
12
|
+
}
|
|
13
|
+
.card {
|
|
14
|
+
max-width: 860px;
|
|
15
|
+
margin: 0 auto;
|
|
16
|
+
background: #fff;
|
|
17
|
+
border-radius: 8px;
|
|
18
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
}
|
|
21
|
+
.header {
|
|
22
|
+
background: #1a73e8;
|
|
23
|
+
color: #fff;
|
|
24
|
+
padding: 16px 24px;
|
|
25
|
+
}
|
|
26
|
+
.header h1 {
|
|
27
|
+
margin: 0;
|
|
28
|
+
font-size: 1.25rem;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
}
|
|
31
|
+
.header p {
|
|
32
|
+
margin: 4px 0 0;
|
|
33
|
+
font-size: 0.9rem;
|
|
34
|
+
opacity: 0.85;
|
|
35
|
+
}
|
|
36
|
+
.meta {
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-wrap: wrap;
|
|
39
|
+
gap: 12px;
|
|
40
|
+
padding: 14px 24px;
|
|
41
|
+
background: #f8f9fa;
|
|
42
|
+
border-bottom: 1px solid #e0e0e0;
|
|
43
|
+
font-size: 0.88rem;
|
|
44
|
+
color: #444;
|
|
45
|
+
}
|
|
46
|
+
.meta span strong {
|
|
47
|
+
color: #222;
|
|
48
|
+
}
|
|
49
|
+
.score {
|
|
50
|
+
display: flex;
|
|
51
|
+
gap: 12px;
|
|
52
|
+
padding: 12px 24px;
|
|
53
|
+
border-bottom: 1px solid #e0e0e0;
|
|
54
|
+
font-size: 0.9rem;
|
|
55
|
+
}
|
|
56
|
+
.score .pass-count {
|
|
57
|
+
color: #2e7d32;
|
|
58
|
+
font-weight: 600;
|
|
59
|
+
}
|
|
60
|
+
.score .fail-count {
|
|
61
|
+
color: #c62828;
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
}
|
|
64
|
+
.output {
|
|
65
|
+
padding: 16px 24px;
|
|
66
|
+
}
|
|
67
|
+
.output h2 {
|
|
68
|
+
font-size: 0.95rem;
|
|
69
|
+
font-weight: 600;
|
|
70
|
+
margin: 0 0 8px;
|
|
71
|
+
color: #333;
|
|
72
|
+
}
|
|
73
|
+
pre {
|
|
74
|
+
margin: 0;
|
|
75
|
+
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
|
76
|
+
font-size: 0.82rem;
|
|
77
|
+
line-height: 1.6;
|
|
78
|
+
background: #f8f9fa;
|
|
79
|
+
border: 1px solid #e0e0e0;
|
|
80
|
+
border-radius: 6px;
|
|
81
|
+
padding: 12px 14px;
|
|
82
|
+
white-space: pre-wrap;
|
|
83
|
+
word-break: break-word;
|
|
84
|
+
}
|
|
85
|
+
.pass { color: #2e7d32; font-weight: 600; }
|
|
86
|
+
.fail { color: #c62828; font-weight: 600; }
|
|
87
|
+
.error { color: #e65100; font-weight: 600; }
|
|
88
|
+
</style>
|
|
89
|
+
</head>
|
|
90
|
+
<body>
|
|
91
|
+
<div class="card">
|
|
92
|
+
<div class="header">
|
|
93
|
+
<h1>CodEval Results</h1>
|
|
94
|
+
<p>{{ASSIGNMENT_NAME}}</p>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="meta">
|
|
97
|
+
<span><strong>Student:</strong> {{STUDENT_NAME}}</span>
|
|
98
|
+
<span><strong>Submitted:</strong> {{SUBMITTED}}</span>
|
|
99
|
+
<span><strong>Attempt:</strong> {{ATTEMPT}}</span>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="score">
|
|
102
|
+
<span class="pass-count">✓ {{PASS_COUNT}} passed</span>
|
|
103
|
+
<span class="fail-count">✗ {{FAIL_COUNT}} failed</span>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="output">
|
|
106
|
+
<h2>Output</h2>
|
|
107
|
+
<pre>{{OUTPUT}}</pre>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assignment-codeval
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.26
|
|
4
4
|
Summary: CodEval for evaluating programming assignments
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -69,6 +69,7 @@ Tags used in a spec file (\<course name>.codeval)
|
|
|
69
69
|
| CF | Check Function | Will be followed by a function name and a list of files to check to ensure that the function is used by one of those files. |
|
|
70
70
|
| CC | Check Container | Will be followed by a function name and a list of files to check to ensure that a container is used by one of those files. Primarily supports C++ containers such as std::vector |
|
|
71
71
|
| CO | Check Object | Will be followed by a function name and a list of files to check to ensure that an object is used by one of those files. Primarily support C++ stream operations |
|
|
72
|
+
| PRINT | Print Label | Prints a label/message to stdout. Cleaner alternative to `CMD echo "..."` for section labels. |
|
|
72
73
|
| CMD/TCMD | Run Command | Will be followed by a command to run. The TCMD will cause the evaluation to fail if the command exits with an error. |
|
|
73
74
|
| CMP | Compare | Will be followed by two files to compare. |
|
|
74
75
|
| T/HT | Test Case | Will be followed by the command to run to test the submission. |
|
|
@@ -2,18 +2,20 @@ assignment_codeval/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
|
|
|
2
2
|
assignment_codeval/ai_benchmark.py,sha256=ZGWWrBeUcaArkuQqS9wY42gxP8g7J2nivJMedYq9RFs,18045
|
|
3
3
|
assignment_codeval/canvas_utils.py,sha256=MNtGXc93tHlqudEZsb_6r-5QzV9tSqf3AVxIVGEmk-U,5989
|
|
4
4
|
assignment_codeval/check_grading.py,sha256=VFbkAG_PrBi6iFeyqHs7mLKPi0Xjccg6WtLwY9xrRIk,5880
|
|
5
|
-
assignment_codeval/cli.py,sha256=
|
|
5
|
+
assignment_codeval/cli.py,sha256=Vl27zNI7kI0QEZoXTIE2aTrraEJWj9vgJiyVWou3vgU,1357
|
|
6
6
|
assignment_codeval/commons.py,sha256=rr09qHacHoLmmU8S9sBss4_utb0DH8-IyL9Dsj0ycE0,1412
|
|
7
7
|
assignment_codeval/convertMD2Html.py,sha256=4yu41U4Up1oGI5vDZGelmuogwFmEZrRS55M4ZHbRfaE,6243
|
|
8
8
|
assignment_codeval/create_assignment.py,sha256=oZ-0LadVb6R-rXVg0Lz5itziaalIvFoxd71cIxgMfow,10815
|
|
9
|
-
assignment_codeval/evaluate.py,sha256=
|
|
9
|
+
assignment_codeval/evaluate.py,sha256=er21lA-eohP1BfHPYPWRw9m0a_IOeljMNtcidOYacJY,36253
|
|
10
|
+
assignment_codeval/export_tests.py,sha256=IDAlGAp3zk-K5AFwLr85v4w9_WcYr6HXVF0joi4eYTg,5467
|
|
10
11
|
assignment_codeval/file_utils.py,sha256=RDgAgmyIOLKvRlxbAr-zILz2A24zSr-r3--p-dxl4G8,2132
|
|
11
12
|
assignment_codeval/github_connect.py,sha256=AP7mZPhzKKK8fxS-3JMfG6AQ-1wUutfsq-Bt-KG9WUs,4270
|
|
12
13
|
assignment_codeval/install_assignment.py,sha256=HsXzTex4G-U3t4tb-HyQ2AobBDBPcKeR6oAUDFqBP-A,4599
|
|
13
14
|
assignment_codeval/recent_comments.py,sha256=e8UQL2_9dsPlf-NYFrGSaM6h6XYnTf_MW2jWhtEsb38,9097
|
|
14
|
-
assignment_codeval/submissions.py,sha256=
|
|
15
|
-
assignment_codeval
|
|
16
|
-
assignment_codeval-0.0.
|
|
17
|
-
assignment_codeval-0.0.
|
|
18
|
-
assignment_codeval-0.0.
|
|
19
|
-
assignment_codeval-0.0.
|
|
15
|
+
assignment_codeval/submissions.py,sha256=YbwKynZwWsmcyytwRi3Kuxq3WN-V-vvshO9JrNJoxAs,28333
|
|
16
|
+
assignment_codeval/test_template.html,sha256=lgWsin5sYE_Pwjp3xqa7_UT-yZ4dWrVqAvNlSBBiNMI,2584
|
|
17
|
+
assignment_codeval-0.0.26.dist-info/METADATA,sha256=CvKtHeb1kR3x4Sz0ojGIKui1v_Q1ejiColTIShfMOfg,15589
|
|
18
|
+
assignment_codeval-0.0.26.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
19
|
+
assignment_codeval-0.0.26.dist-info/entry_points.txt,sha256=Pk0eAhsXBpCsgkv6HYLSHTRf5x_zUf-Lg8KK9KsK2B8,66
|
|
20
|
+
assignment_codeval-0.0.26.dist-info/top_level.txt,sha256=a_P8W2IWjAELxKlTV2t4rLwWVoTxElxUMvWgWjR_1n4,19
|
|
21
|
+
assignment_codeval-0.0.26.dist-info/RECORD,,
|
|
File without changes
|
{assignment_codeval-0.0.24.dist-info → assignment_codeval-0.0.26.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|