assignment-codeval 0.0.24__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.24 → assignment_codeval-0.0.25}/PKG-INFO +2 -1
  2. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/README.md +1 -0
  3. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/pyproject.toml +4 -1
  4. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/cli.py +2 -0
  5. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/evaluate.py +226 -39
  6. assignment_codeval-0.0.25/src/assignment_codeval/export_tests.py +187 -0
  7. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/submissions.py +156 -22
  8. assignment_codeval-0.0.25/src/assignment_codeval/test_template.html +111 -0
  9. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/PKG-INFO +2 -1
  10. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/SOURCES.txt +4 -0
  11. assignment_codeval-0.0.25/tests/test_export_tests.py +172 -0
  12. assignment_codeval-0.0.25/tests/test_function_detection.py +195 -0
  13. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/setup.cfg +0 -0
  14. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/__init__.py +0 -0
  15. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/ai_benchmark.py +0 -0
  16. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/canvas_utils.py +0 -0
  17. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/check_grading.py +0 -0
  18. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/commons.py +0 -0
  19. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/convertMD2Html.py +0 -0
  20. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/create_assignment.py +0 -0
  21. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/file_utils.py +0 -0
  22. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/github_connect.py +0 -0
  23. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/install_assignment.py +0 -0
  24. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval/recent_comments.py +0 -0
  25. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
  26. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
  27. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/requires.txt +0 -0
  28. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/src/assignment_codeval.egg-info/top_level.txt +0 -0
  29. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/tests/test_check_grading.py +0 -0
  30. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/tests/test_codeval.py +0 -0
  31. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/tests/test_create_assignment.py +0 -0
  32. {assignment_codeval-0.0.24 → assignment_codeval-0.0.25}/tests/test_evaluate_submissions.py +0 -0
  33. {assignment_codeval-0.0.24 → 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.24
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.24"
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 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
@@ -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}")