assignment-codeval 0.0.23__tar.gz → 0.0.25__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 (33) hide show
  1. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/PKG-INFO +2 -1
  2. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/README.md +1 -0
  3. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/pyproject.toml +4 -1
  4. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/canvas_utils.py +75 -0
  5. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/check_grading.py +7 -79
  6. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/cli.py +2 -0
  7. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/evaluate.py +226 -39
  8. assignment_codeval-0.0.25/src/assignment_codeval/export_tests.py +187 -0
  9. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/recent_comments.py +55 -36
  10. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/submissions.py +156 -22
  11. assignment_codeval-0.0.25/src/assignment_codeval/test_template.html +111 -0
  12. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/PKG-INFO +2 -1
  13. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/SOURCES.txt +4 -0
  14. assignment_codeval-0.0.25/tests/test_export_tests.py +172 -0
  15. assignment_codeval-0.0.25/tests/test_function_detection.py +195 -0
  16. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/setup.cfg +0 -0
  17. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/__init__.py +0 -0
  18. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/ai_benchmark.py +0 -0
  19. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/commons.py +0 -0
  20. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/convertMD2Html.py +0 -0
  21. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/create_assignment.py +0 -0
  22. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/file_utils.py +0 -0
  23. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/github_connect.py +0 -0
  24. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval/install_assignment.py +0 -0
  25. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
  26. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
  27. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/requires.txt +0 -0
  28. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/top_level.txt +0 -0
  29. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/tests/test_check_grading.py +0 -0
  30. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/tests/test_codeval.py +0 -0
  31. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/tests/test_create_assignment.py +0 -0
  32. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/tests/test_evaluate_submissions.py +0 -0
  33. {assignment_codeval-0.0.23 → assignment_codeval-0.0.25}/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.23
3
+ Version: 0.0.25
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.23"
7
+ version = "0.0.25"
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 functools import cache
5
5
  from typing import NamedTuple
6
6
 
7
7
  import click
8
+ import requests
8
9
  from canvasapi import Canvas
9
10
  from canvasapi.current_user import CurrentUser
10
11
 
@@ -107,3 +108,77 @@ def get_assignment(course, assignment_name):
107
108
  sys.exit(2)
108
109
  assignment = assignments[0]
109
110
  return assignment
111
+
112
+
113
+ SUBMISSIONS_QUERY = """
114
+ query SubmissionsQuery($assignmentId: ID!, $cursor: String) {
115
+ assignment(id: $assignmentId) {
116
+ submissionsConnection(after: $cursor) {
117
+ pageInfo {
118
+ hasNextPage
119
+ endCursor
120
+ }
121
+ nodes {
122
+ _id
123
+ submittedAt
124
+ user {
125
+ name
126
+ }
127
+ commentsConnection(filter: {allComments: true}, sortOrder: desc) {
128
+ nodes {
129
+ comment
130
+ createdAt
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ """
138
+
139
+
140
+ def get_canvas_credentials():
141
+ """Read raw url and token from codeval.ini config file."""
142
+ parser = ConfigParser()
143
+ config_file = click.get_app_dir("codeval.ini")
144
+ parser.read(config_file)
145
+ if 'SERVER' not in parser:
146
+ error(f"did not find [SERVER] section in {config_file}.")
147
+ sys.exit(1)
148
+ for key in ['url', 'token']:
149
+ if key not in parser['SERVER']:
150
+ error(f"did not find {key} in [SERVER] in {config_file}.")
151
+ sys.exit(1)
152
+ return parser['SERVER']['url'], parser['SERVER']['token']
153
+
154
+
155
+ def graphql_request(base_url, token, query, variables):
156
+ """POST a GraphQL query to Canvas and return the data."""
157
+ url = f"{base_url}/api/graphql"
158
+ headers = {"Authorization": f"Bearer {token}"}
159
+ payload = {"query": query, "variables": variables}
160
+ resp = requests.post(url, json=payload, headers=headers)
161
+ resp.raise_for_status()
162
+ result = resp.json()
163
+ if "errors" in result:
164
+ error(f"GraphQL errors: {result['errors']}")
165
+ sys.exit(1)
166
+ return result["data"]
167
+
168
+
169
+ def fetch_all_submissions(base_url, token, assignment_id):
170
+ """Fetch all submissions for an assignment using relay pagination."""
171
+ all_nodes = []
172
+ cursor = None
173
+ while True:
174
+ variables = {"assignmentId": str(assignment_id)}
175
+ if cursor:
176
+ variables["cursor"] = cursor
177
+ data = graphql_request(base_url, token, SUBMISSIONS_QUERY, variables)
178
+ conn = data["assignment"]["submissionsConnection"]
179
+ all_nodes.extend(conn["nodes"])
180
+ if conn["pageInfo"]["hasNextPage"]:
181
+ cursor = conn["pageInfo"]["endCursor"]
182
+ else:
183
+ break
184
+ return all_nodes
@@ -1,14 +1,15 @@
1
1
  """Check which submissions are missing a codeval comment newer than the submission."""
2
2
 
3
3
  import sys
4
- from configparser import ConfigParser
5
4
  from datetime import datetime, timedelta, timezone
6
5
  from math import floor
7
6
 
8
7
  import click
9
- import requests
10
8
 
11
- from assignment_codeval.canvas_utils import connect_to_canvas, get_course
9
+ from assignment_codeval.canvas_utils import (
10
+ connect_to_canvas, get_course,
11
+ get_canvas_credentials, graphql_request, fetch_all_submissions,
12
+ )
12
13
  from assignment_codeval.commons import debug, error
13
14
 
14
15
  ASSIGNMENTS_QUERY = """
@@ -29,79 +30,6 @@ query AssignmentsQuery($courseId: ID!) {
29
30
  }
30
31
  """
31
32
 
32
- SUBMISSIONS_QUERY = """
33
- query SubmissionsQuery($assignmentId: ID!, $cursor: String) {
34
- assignment(id: $assignmentId) {
35
- submissionsConnection(after: $cursor) {
36
- pageInfo {
37
- hasNextPage
38
- endCursor
39
- }
40
- nodes {
41
- _id
42
- submittedAt
43
- user {
44
- name
45
- }
46
- commentsConnection(filter: {allComments: true}) {
47
- nodes {
48
- comment
49
- createdAt
50
- }
51
- }
52
- }
53
- }
54
- }
55
- }
56
- """
57
-
58
-
59
- def _get_canvas_credentials():
60
- """Read raw url and token from codeval.ini config file."""
61
- parser = ConfigParser()
62
- config_file = click.get_app_dir("codeval.ini")
63
- parser.read(config_file)
64
- if 'SERVER' not in parser:
65
- error(f"did not find [SERVER] section in {config_file}.")
66
- sys.exit(1)
67
- for key in ['url', 'token']:
68
- if key not in parser['SERVER']:
69
- error(f"did not find {key} in [SERVER] in {config_file}.")
70
- sys.exit(1)
71
- return parser['SERVER']['url'], parser['SERVER']['token']
72
-
73
-
74
- def _graphql_request(base_url, token, query, variables):
75
- """POST a GraphQL query to Canvas and return the data."""
76
- url = f"{base_url}/api/graphql"
77
- headers = {"Authorization": f"Bearer {token}"}
78
- payload = {"query": query, "variables": variables}
79
- resp = requests.post(url, json=payload, headers=headers)
80
- resp.raise_for_status()
81
- result = resp.json()
82
- if "errors" in result:
83
- error(f"GraphQL errors: {result['errors']}")
84
- sys.exit(1)
85
- return result["data"]
86
-
87
-
88
- def _fetch_all_submissions(base_url, token, assignment_id):
89
- """Fetch all submissions for an assignment using relay pagination."""
90
- all_nodes = []
91
- cursor = None
92
- while True:
93
- variables = {"assignmentId": str(assignment_id)}
94
- if cursor:
95
- variables["cursor"] = cursor
96
- data = _graphql_request(base_url, token, SUBMISSIONS_QUERY, variables)
97
- conn = data["assignment"]["submissionsConnection"]
98
- all_nodes.extend(conn["nodes"])
99
- if conn["pageInfo"]["hasNextPage"]:
100
- cursor = conn["pageInfo"]["endCursor"]
101
- else:
102
- break
103
- return all_nodes
104
-
105
33
 
106
34
  def _format_elapsed(submitted_at_str):
107
35
  """Format elapsed time since submission as a human-readable string."""
@@ -160,10 +88,10 @@ def check_grading(course_name, assignment_group, codeval_prefix, verbose, warn,
160
88
  """
161
89
  (canvas, user) = connect_to_canvas()
162
90
  course = get_course(canvas, course_name)
163
- base_url, token = _get_canvas_credentials()
91
+ base_url, token = get_canvas_credentials()
164
92
 
165
93
  debug(f"fetching assignment groups for course {course.name} (id={course.id})")
166
- data = _graphql_request(base_url, token, ASSIGNMENTS_QUERY, {"courseId": str(course.id)})
94
+ data = graphql_request(base_url, token, ASSIGNMENTS_QUERY, {"courseId": str(course.id)})
167
95
 
168
96
  groups = data["course"]["assignmentGroupsConnection"]["nodes"]
169
97
  target_group = None
@@ -192,7 +120,7 @@ def check_grading(course_name, assignment_group, codeval_prefix, verbose, warn,
192
120
  assignment_name = assignment["name"]
193
121
  debug(f"checking assignment: {assignment_name}")
194
122
 
195
- submissions = _fetch_all_submissions(base_url, token, assignment_id)
123
+ submissions = fetch_all_submissions(base_url, token, assignment_id)
196
124
 
197
125
  missing = []
198
126
  warned = []
@@ -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
@@ -80,52 +81,178 @@ def compile_code(compile_command):
80
81
  sys.exit(1)
81
82
 
82
83
 
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.
84
+ ###########################################################
85
+ # Function detection helpers
86
+ ###########################################################
86
87
 
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
88
 
91
- Returns:
92
- None
89
+ def _detect_language(files):
90
+ """Return 'c_cpp', 'java', 'python', or 'unknown' based on file extensions."""
91
+ for f in files:
92
+ ext = os.path.splitext(f)[1].lower()
93
+ if ext in ('.c', '.cpp', '.cc', '.cxx', '.h', '.hpp'):
94
+ return 'c_cpp'
95
+ if ext == '.java':
96
+ return 'java'
97
+ if ext == '.py':
98
+ return 'python'
99
+ return 'unknown'
100
+
101
+
102
+ def _find_compiled_artifact(source_file):
103
+ """Return the compiled artifact for a source file, or None if not found.
104
+
105
+ Checks (in order): same base name with .o, .exe, and no extension.
93
106
  """
94
- check_test()
95
- args = args.split()
96
- function_name = args[0]
97
- files = args[1:]
107
+ base = os.path.splitext(source_file)[0]
108
+ for candidate in (base + '.o', base + '.exe', base):
109
+ if os.path.exists(candidate):
110
+ return candidate
111
+ return None
98
112
 
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
113
 
106
- # Strip comments, then search
114
+ def _function_used_in_c_cpp(function_name, files):
115
+ """Use objdump on compiled artifacts to detect C/C++ function usage.
116
+
117
+ Returns True if found, False if artifact exists but function absent,
118
+ None if no compiled artifact was found (caller should fall back).
119
+ """
120
+ found_artifact = False
121
+ for source_file in files:
122
+ artifact = _find_compiled_artifact(source_file)
123
+ if artifact is None:
124
+ continue
125
+ found_artifact = True
126
+ # objdump -t dumps the symbol table; pipe through c++filt to demangle C++ names.
127
+ # Mangled C++ names embed the original identifier, so grep -w finds exact matches.
128
+ cmd = f"objdump -t '{artifact}' | c++filt | grep -w '{function_name}'"
129
+ result = subprocess.run(
130
+ ["bash", "-c", cmd],
131
+ stdout=subprocess.PIPE,
132
+ stderr=subprocess.PIPE,
133
+ )
134
+ if result.returncode == 0:
135
+ return True
136
+ if not found_artifact:
137
+ return None
138
+ return False
139
+
140
+
141
+ def _function_used_in_java(function_name, files):
142
+ """Use javap on compiled .class files to detect Java method usage.
143
+
144
+ Returns True if found, False if class file exists but method absent,
145
+ None if no .class file was found (caller should fall back).
146
+ """
147
+ found_class = False
148
+ for source_file in files:
149
+ base = os.path.splitext(source_file)[0]
150
+ class_file = base + '.class'
151
+ if not os.path.exists(class_file):
152
+ continue
153
+ found_class = True
154
+ # javap -c disassembles bytecode; method invocations reference the method name.
155
+ result = subprocess.run(
156
+ ["javap", "-c", class_file],
157
+ stdout=subprocess.PIPE,
158
+ stderr=subprocess.PIPE,
159
+ )
160
+ if function_name.encode() in result.stdout:
161
+ return True
162
+ if not found_class:
163
+ return None
164
+ return False
165
+
166
+
167
+ def _function_used_in_python(function_name, files):
168
+ """Use the ast module to detect function calls in Python source files.
169
+
170
+ Returns True if a matching call is found, False otherwise.
171
+ Handles both direct calls (func()) and attribute calls (obj.func()).
172
+ """
173
+ for source_file in files:
174
+ try:
175
+ with open(source_file, 'r') as f:
176
+ source = f.read()
177
+ tree = ast.parse(source)
178
+ for node in ast.walk(tree):
179
+ if isinstance(node, ast.Call):
180
+ func = node.func
181
+ if isinstance(func, ast.Name) and func.id == function_name:
182
+ return True
183
+ if isinstance(func, ast.Attribute) and func.attr == function_name:
184
+ return True
185
+ except (SyntaxError, OSError):
186
+ continue
187
+ return False
188
+
189
+
190
+ def _function_used_regex(function_name, files):
191
+ """Legacy regex-based fallback used when no compiled artifact is available."""
192
+ regex = rf'(^|[^[:alnum:]_]){function_name}[[:space:]]*\('
107
193
  cmd = (
108
194
  "sed -E "
109
195
  "'"
110
- "s://.*$::g; " # C/C++ single-line comments
111
- "s:#.*$::g; " # Python single-line comments
196
+ "s://.*$::g; "
197
+ "s:#.*$::g; "
112
198
  ":a; /\\/\\*/{N; s:/\\*.*?\\*/::g; ba}"
113
199
  "' "
114
200
  + " ".join(files)
115
201
  + f" | grep -E '{regex}'"
116
202
  )
117
-
118
- function_popen = subprocess.Popen(
203
+ result = subprocess.run(
119
204
  ["bash", "-c", cmd],
120
205
  stdout=subprocess.PIPE,
121
206
  stderr=subprocess.PIPE,
122
207
  )
208
+ return result.returncode == 0
123
209
 
124
- function_popen.communicate()
125
- if function_popen.returncode:
126
- print(f"Not using {function_name} FAILED")
127
- else:
210
+
211
+ def _is_function_used(function_name, files):
212
+ """Detect whether function_name is used in the given files.
213
+
214
+ Dispatches to the appropriate tool based on file language:
215
+ - C/C++: objdump on compiled artifact (falls back to regex if none found)
216
+ - Java: javap on .class file (falls back to regex if none found)
217
+ - Python: ast module on source
218
+ - Other: regex on source
219
+ """
220
+ lang = _detect_language(files)
221
+ if lang == 'python':
222
+ return _function_used_in_python(function_name, files)
223
+ if lang == 'java':
224
+ result = _function_used_in_java(function_name, files)
225
+ if result is None:
226
+ return _function_used_regex(function_name, files)
227
+ return result
228
+ if lang == 'c_cpp':
229
+ result = _function_used_in_c_cpp(function_name, files)
230
+ if result is None:
231
+ return _function_used_regex(function_name, files)
232
+ return result
233
+ return _function_used_regex(function_name, files)
234
+
235
+
236
+ def check_function(args):
237
+ """Will be followed by a function name and a list of files to check to ensure that the function
238
+ is used by one of those files.
239
+
240
+ Arguments:
241
+ function_name: the function name to check files for usage of
242
+ *files: the files to check for the function name
243
+
244
+ Returns:
245
+ None
246
+ """
247
+ check_test()
248
+ args = args.split()
249
+ function_name = args[0]
250
+ files = args[1:]
251
+
252
+ if _is_function_used(function_name, files):
128
253
  print(f"Used {function_name} PASSED")
254
+ else:
255
+ print(f"Not using {function_name} FAILED")
129
256
 
130
257
  def check_object(args):
131
258
  """Will be followed by an object and a list of files to ensure that the function is
@@ -228,7 +355,7 @@ def check_not_function(args):
228
355
  is not used by any of those files.
229
356
 
230
357
  Arguments:
231
- function_name: the funcion name to check files for usage of
358
+ function_name: the function name to check files for usage of
232
359
  *files: the files to check for the function name
233
360
 
234
361
  Returns:
@@ -239,18 +366,23 @@ def check_not_function(args):
239
366
  function_name = args[0]
240
367
  files = args[1:]
241
368
 
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")
369
+ if _is_function_used(function_name, files):
370
+ print(f"Used {function_name} FAILED")
252
371
  else:
253
- print(f"not using {function_name} FAILED")
372
+ print(f"Not using {function_name} PASSED")
373
+
374
+
375
+ def print_label(text):
376
+ """Print a label/message to stdout.
377
+
378
+ Arguments:
379
+ text: the text to print
380
+
381
+ Returns:
382
+ None
383
+ """
384
+ check_test()
385
+ print(text)
254
386
 
255
387
 
256
388
  def run_command(command):
@@ -578,6 +710,7 @@ tag_func_map = {
578
710
  "CO" : check_object,
579
711
  "CC": check_container,
580
712
  "NCF": check_not_function,
713
+ "PRINT": print_label,
581
714
  "CMD": run_command,
582
715
  "TCMD": run_command_noerror,
583
716
  "CMP": compare,
@@ -774,6 +907,27 @@ def parse_diff(diff_lines: list[str], testing_dir: str):
774
907
  outfile.write(line)
775
908
 
776
909
 
910
+ def _find_touched_files(pre_run_timestamp):
911
+ """Find files touched (modified or accessed) since pre_run_timestamp.
912
+
913
+ Recursively scans the current working directory, ignoring TESTING_DIR.
914
+ """
915
+ touched = []
916
+ for dirpath, dirnames, filenames in os.walk("."):
917
+ # Prune TESTING_DIR from traversal
918
+ if TESTING_DIR in dirnames:
919
+ dirnames.remove(TESTING_DIR)
920
+ for filename in filenames:
921
+ filepath = os.path.join(dirpath, filename)
922
+ try:
923
+ st = os.stat(filepath)
924
+ if st.st_mtime > pre_run_timestamp or st.st_atime > pre_run_timestamp:
925
+ touched.append(filepath)
926
+ except OSError:
927
+ continue
928
+ return touched
929
+
930
+
777
931
  def check_test():
778
932
  global test_args
779
933
  if test_args == "":
@@ -782,6 +936,9 @@ def check_test():
782
936
  print(f"Test case {test_case_count} of {test_case_total}")
783
937
  passed = True
784
938
 
939
+ # Record timestamp before running student code for file tracking
940
+ pre_run_timestamp = time.time()
941
+
785
942
  with open(get_testing_path("fileinput"), "rb") as fileinput, open(
786
943
  get_testing_path("youroutput"), "w"
787
944
  ) as youroutput, open(get_testing_path("yourerror"), "w") as yourerror:
@@ -797,6 +954,9 @@ def check_test():
797
954
  print(f"Took more than {timeout_val} seconds to run. FAIL")
798
955
  passed = False
799
956
 
957
+ # Find files touched by student's program
958
+ touched_files = _find_touched_files(pre_run_timestamp)
959
+
800
960
  # Difflog handling
801
961
  with open(get_testing_path("difflog"), "w") as outfile:
802
962
  diff_popen = subprocess.Popen(
@@ -872,6 +1032,33 @@ def check_test():
872
1032
  # Print entire file
873
1033
  print("".join(file_lines))
874
1034
 
1035
+ # Show diffs for files touched by student's program
1036
+ if touched_files and cmps:
1037
+ expected_map = {}
1038
+ for file_pair in cmps:
1039
+ if len(file_pair) >= 2:
1040
+ expected_map[os.path.normpath(file_pair[0])] = file_pair[1]
1041
+
1042
+ for filepath in touched_files:
1043
+ normalized = os.path.normpath(filepath)
1044
+ if normalized in expected_map:
1045
+ expected_file = expected_map[normalized]
1046
+ if os.path.exists(expected_file):
1047
+ diff_proc = subprocess.Popen(
1048
+ ["diff", "-U1", "-a", filepath, expected_file],
1049
+ stdout=subprocess.PIPE,
1050
+ stderr=subprocess.PIPE,
1051
+ text=True,
1052
+ )
1053
+ diff_output, _ = diff_proc.communicate()
1054
+ if diff_output:
1055
+ print(f" File output diff ({os.path.basename(filepath)}):")
1056
+ lines = diff_output.splitlines()
1057
+ for line in lines[:22]:
1058
+ print(f" {line}")
1059
+ if len(lines) > 22:
1060
+ print(f" ... ({len(lines) - 22} more lines)")
1061
+
875
1062
  cleanup()
876
1063
 
877
1064
  # Exit program after failed test case