assignment-codeval 0.0.24__tar.gz → 0.0.26__tar.gz

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.
Files changed (34) hide show
  1. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/PKG-INFO +2 -1
  2. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/README.md +1 -0
  3. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/pyproject.toml +4 -1
  4. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/cli.py +2 -0
  5. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/evaluate.py +317 -51
  6. assignment_codeval-0.0.26/src/assignment_codeval/export_tests.py +187 -0
  7. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/submissions.py +192 -20
  8. assignment_codeval-0.0.26/src/assignment_codeval/test_template.html +111 -0
  9. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval.egg-info/PKG-INFO +2 -1
  10. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval.egg-info/SOURCES.txt +4 -0
  11. assignment_codeval-0.0.26/tests/test_codeval.py +77 -0
  12. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/tests/test_evaluate_submissions.py +154 -0
  13. assignment_codeval-0.0.26/tests/test_export_tests.py +172 -0
  14. assignment_codeval-0.0.26/tests/test_function_detection.py +195 -0
  15. assignment_codeval-0.0.24/tests/test_codeval.py +0 -35
  16. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/setup.cfg +0 -0
  17. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/__init__.py +0 -0
  18. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/ai_benchmark.py +0 -0
  19. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/canvas_utils.py +0 -0
  20. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/check_grading.py +0 -0
  21. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/commons.py +0 -0
  22. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/convertMD2Html.py +0 -0
  23. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/create_assignment.py +0 -0
  24. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/file_utils.py +0 -0
  25. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/github_connect.py +0 -0
  26. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/install_assignment.py +0 -0
  27. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval/recent_comments.py +0 -0
  28. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
  29. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
  30. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval.egg-info/requires.txt +0 -0
  31. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/src/assignment_codeval.egg-info/top_level.txt +0 -0
  32. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/tests/test_check_grading.py +0 -0
  33. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/tests/test_create_assignment.py +0 -0
  34. {assignment_codeval-0.0.24 → assignment_codeval-0.0.26}/tests/test_install_assignment.py +0 -0
@@ -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. |
@@ -49,6 +49,7 @@ Tags used in a spec file (\<course name>.codeval)
49
49
  | 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. |
50
50
  | 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 |
51
51
  | 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 |
52
+ | PRINT | Print Label | Prints a label/message to stdout. Cleaner alternative to `CMD echo "..."` for section labels. |
52
53
  | 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. |
53
54
  | CMP | Compare | Will be followed by two files to compare. |
54
55
  | T/HT | Test Case | Will be followed by the command to run to test the submission. |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assignment-codeval"
7
- version = "0.0.24"
7
+ version = "0.0.26"
8
8
  description = "CodEval for evaluating programming assignments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -27,3 +27,6 @@ test = ["pytest>=7.0"]
27
27
 
28
28
  [project.scripts]
29
29
  assignment-codeval = "assignment_codeval.cli:cli"
30
+
31
+ [tool.setuptools.package-data]
32
+ assignment_codeval = ["*.html"]
@@ -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