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 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__":
@@ -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
- def check_function(args):
84
- """Will be followed by a function name and a list of files to check to ensure that the function
85
- is used by one of those files.
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
- Returns:
92
- None
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
- check_test()
95
- args = args.split()
96
- function_name = args[0]
97
- files = args[1:]
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
- # Strip comments, then search
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; " # C/C++ single-line comments
111
- "s:#.*$::g; " # Python single-line comments
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
- function_popen.communicate()
125
- if function_popen.returncode:
126
- print(f"Not using {function_name} FAILED")
127
- else:
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 funcion name to check files for usage of
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
- # Surpress output
243
- function_popen = subprocess.Popen(
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"not using {function_name} FAILED")
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
- f"diff -U1 -a ./{TESTING_DIR}/youroutput ./{TESTING_DIR}/expectedoutput | cat -te | head -22",
804
- shell=True,
805
- stdout=outfile,
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
- f"diff -U1 -a ./{TESTING_DIR}/yourerror ./{TESTING_DIR}/expectederror | cat -te | head -22",
815
- shell=True,
816
- stdout=outfile,
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
- def upload_submission_comments(submissions_dir, codeval_prefix):
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
- match = re.match(fr'^{clean_submissions_dir}/([^/]+)/([^/]+)/([^/]+)$', dirpath)
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
- if "comments.txt.sent" in filenames:
87
- info(f"skipping already uploaded comments for {student_id} in {course_name}: {assignment_name}")
88
- else:
89
- info(f"uploading comments for {student_id} in {course_name}: {assignment_name}")
90
- course = get_course(canvas, course_name)
91
- assignment = get_assignment(course, assignment_name)
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("<", "&lt;")
96
- submission = get_submissions_by_id(assignment).get(student_id)
97
- if submission:
98
- submission.edit(comment={'text_comment': f'{codeval_prefix}<pre>\n{comment}</pre>'})
99
- else:
100
- warn(f"no submission found for {student_id} in {course_name}: {assignment_name}")
101
- with open(f"{dirpath}/comments.txt.sent", "w") as fd:
102
- fd.write(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
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">&#10003; {{PASS_COUNT}} passed</span>
103
+ <span class="fail-count">&#10007; {{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.24
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=CimNl-0urFsjSK-76WFtBjYEWpFngoqgR9g7TaTSRzE,1270
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=Wf9KnMqqgxXrodme_me8SDfX-QCpPqZTDP-XqB7B9sA,26796
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=OW6wotV8oKcW4g5E0kSfybRbOL62_CD4FmKyiFyCH3E,20966
15
- assignment_codeval-0.0.24.dist-info/METADATA,sha256=_41zKTIp2QSibs_mQJGnXoVtKpMRCMhv3Upfw8n-6fY,15469
16
- assignment_codeval-0.0.24.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
17
- assignment_codeval-0.0.24.dist-info/entry_points.txt,sha256=Pk0eAhsXBpCsgkv6HYLSHTRf5x_zUf-Lg8KK9KsK2B8,66
18
- assignment_codeval-0.0.24.dist-info/top_level.txt,sha256=a_P8W2IWjAELxKlTV2t4rLwWVoTxElxUMvWgWjR_1n4,19
19
- assignment_codeval-0.0.24.dist-info/RECORD,,
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,,