assignment-codeval 0.0.31__tar.gz → 0.0.32__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 (27) hide show
  1. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/PKG-INFO +4 -5
  2. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/README.md +3 -4
  3. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/pyproject.toml +1 -1
  4. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/ai_benchmark.py +12 -3
  5. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/convertMD2Html.py +26 -16
  6. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/create_assignment.py +11 -8
  7. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/evaluate.py +49 -55
  8. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/file_utils.py +15 -10
  9. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/github_connect.py +63 -10
  10. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/recent_comments.py +6 -2
  11. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/submissions.py +124 -26
  12. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/PKG-INFO +4 -5
  13. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/SOURCES.txt +0 -1
  14. assignment_codeval-0.0.31/src/assignment_codeval/t.html +0 -2
  15. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/setup.cfg +0 -0
  16. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/__init__.py +0 -0
  17. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/canvas_utils.py +0 -0
  18. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/check_grading.py +0 -0
  19. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/cli.py +0 -0
  20. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/commons.py +0 -0
  21. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/export_tests.py +0 -0
  22. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/install_assignment.py +0 -0
  23. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/test_template.html +0 -0
  24. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
  25. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
  26. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/requires.txt +0 -0
  27. {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assignment-codeval
3
- Version: 0.0.31
3
+ Version: 0.0.32
4
4
  Summary: CodEval for evaluating programming assignments
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -131,13 +131,13 @@ Codeval files (`.codeval` extension) define how to build, run, and test student
131
131
  | Tag | Meaning | Function |
132
132
  |---|---|---|
133
133
  | C | Compile Code | Specifies the command to compile the submission code |
134
- | CTO | Compile Timeout | Timeout in seconds for the compile command to run |
134
+ | CTO | Compile Timeout | Timeout in seconds for the whole evaluation (compile **and** all test cases) to run |
135
135
  | T/HT | Test Case | Command to run to test the submission (HT = hidden test) |
136
136
  | I/IB/IF | Supply Input | Input for a test case. I adds a newline, IB does not, IF reads from a file |
137
137
  | O/OB/OF | Check Output | Expected output. O adds a newline, OB does not, OF reads from a file |
138
138
  | E/EB | Check Error | Expected error output. E adds a newline, EB does not |
139
- | TO | Timeout | Time limit in seconds for a test case (default: 20) |
140
- | X | Exit Code | Expected exit code for a test case (default: 0) |
139
+ | TO | Timeout | Time limit in seconds for a test case (default: 10) |
140
+ | X | Exit Code | Expected exit code for a test case (not checked unless X is specified) |
141
141
  | TEMP | Temp File | Registers a file to be deleted before and after the next test run |
142
142
  | CF | Check Function | Checks that a function is used in the submission |
143
143
  | NCF | Check Not Function | Checks that a function is **not** used |
@@ -145,7 +145,6 @@ Codeval files (`.codeval` extension) define how to build, run, and test student
145
145
  | PRINT | Print Label | Prints a label/message to stdout |
146
146
  | CMP | Compare | Compares two files |
147
147
  | Z | Download Zip | Zip files to download from Canvas for test cases |
148
- | SS | Start Server | Starts a server with timeout and kill-timeout settings |
149
148
 
150
149
  #### Assignment Description Macros
151
150
 
@@ -110,13 +110,13 @@ Codeval files (`.codeval` extension) define how to build, run, and test student
110
110
  | Tag | Meaning | Function |
111
111
  |---|---|---|
112
112
  | C | Compile Code | Specifies the command to compile the submission code |
113
- | CTO | Compile Timeout | Timeout in seconds for the compile command to run |
113
+ | CTO | Compile Timeout | Timeout in seconds for the whole evaluation (compile **and** all test cases) to run |
114
114
  | T/HT | Test Case | Command to run to test the submission (HT = hidden test) |
115
115
  | I/IB/IF | Supply Input | Input for a test case. I adds a newline, IB does not, IF reads from a file |
116
116
  | O/OB/OF | Check Output | Expected output. O adds a newline, OB does not, OF reads from a file |
117
117
  | E/EB | Check Error | Expected error output. E adds a newline, EB does not |
118
- | TO | Timeout | Time limit in seconds for a test case (default: 20) |
119
- | X | Exit Code | Expected exit code for a test case (default: 0) |
118
+ | TO | Timeout | Time limit in seconds for a test case (default: 10) |
119
+ | X | Exit Code | Expected exit code for a test case (not checked unless X is specified) |
120
120
  | TEMP | Temp File | Registers a file to be deleted before and after the next test run |
121
121
  | CF | Check Function | Checks that a function is used in the submission |
122
122
  | NCF | Check Not Function | Checks that a function is **not** used |
@@ -124,7 +124,6 @@ Codeval files (`.codeval` extension) define how to build, run, and test student
124
124
  | PRINT | Print Label | Prints a label/message to stdout |
125
125
  | CMP | Compare | Compares two files |
126
126
  | Z | Download Zip | Zip files to download from Canvas for test cases |
127
- | SS | Start Server | Starts a server with timeout and kill-timeout settings |
128
127
 
129
128
  #### Assignment Description Macros
130
129
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assignment-codeval"
7
- version = "0.0.31"
7
+ version = "0.0.32"
8
8
  description = "CodEval for evaluating programming assignments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -75,8 +75,10 @@ def extract_assignment_from_codeval(codeval_path: str) -> tuple[str, str, str]:
75
75
  with open(codeval_path, "r", encoding="utf-8") as f:
76
76
  content = f.read()
77
77
 
78
- # Extract content between ASSIGNMENT START / CRT_HW START and ASSIGNMENT END / CRT_HW END
79
- match = re.search(r"(?:ASSIGNMENT START|CRT_HW START) \S+\n(.*?)(?:ASSIGNMENT END|CRT_HW END)", content, re.DOTALL)
78
+ # Extract content between ASSIGNMENT START / CRT_HW START and ASSIGNMENT END / CRT_HW END.
79
+ # The title after the marker may contain spaces (e.g. "CRT_HW START Place Boats")
80
+ # or be absent entirely, so match the rest of the marker line, not a single token.
81
+ match = re.search(r"(?:ASSIGNMENT START|CRT_HW START)[^\n]*\n(.*?)(?:ASSIGNMENT END|CRT_HW END)", content, re.DOTALL)
80
82
  if match:
81
83
  description = match.group(1).strip()
82
84
  else:
@@ -223,10 +225,17 @@ def call_openai(model_id: str, prompt: str, api_key: str) -> Optional[str]:
223
225
  try:
224
226
  client = openai.OpenAI(api_key=api_key)
225
227
 
228
+ # Reasoning models (o1/o3/o4 families, gpt-5) reject `max_tokens` and
229
+ # require `max_completion_tokens` instead.
230
+ if model_id.startswith(("o1", "o3", "o4", "gpt-5")):
231
+ token_kwargs = {"max_completion_tokens": 8192}
232
+ else:
233
+ token_kwargs = {"max_tokens": 8192}
234
+
226
235
  response = client.chat.completions.create(
227
236
  model=model_id,
228
237
  messages=[{"role": "user", "content": prompt}],
229
- max_tokens=8192,
238
+ **token_kwargs,
230
239
  )
231
240
 
232
241
  return response.choices[0].message.content
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import re
3
3
  import zipfile
4
+ from html import escape as html_escape
4
5
 
5
6
  import markdown
6
7
 
@@ -21,13 +22,17 @@ _ANSI_STYLES = {
21
22
 
22
23
 
23
24
  def ansi_to_html(text):
24
- """Convert ANSI escape codes in text to HTML span tags."""
25
+ """Convert ANSI escape codes in text to HTML span tags.
26
+
27
+ The text parts are HTML-escaped so characters like < and & render
28
+ literally instead of being parsed as markup.
29
+ """
25
30
  parts = re.split(r'\x1b\[([\d;]*)m', text)
26
31
  result = []
27
32
  open_spans = 0
28
33
  for i, part in enumerate(parts):
29
34
  if i % 2 == 0:
30
- result.append(part)
35
+ result.append(html_escape(part, quote=False))
31
36
  else:
32
37
  codes = part.split(';') if part else ['0']
33
38
  if '0' in codes:
@@ -66,47 +71,48 @@ def _read_file_content(filename, spec_dir, zip_archives=None):
66
71
  def sampleTestCases(listOfTC, numOfTC, spec_dir=None, zip_archives=None):
67
72
  counter = 0
68
73
  samples = "<pre><code>"
74
+ esc = lambda s: html_escape(s, quote=False)
69
75
  for line in listOfTC:
70
76
  if line.startswith('T ', 0, 2):
71
77
  counter = counter + 1
72
78
  if counter > numOfTC:
73
79
  break
74
- samples = samples + "\n" + "Command to RUN: " + line[2:]
80
+ samples = samples + "\n" + "Command to RUN: " + esc(line[2:])
75
81
  elif line.startswith('IF '):
76
82
  filename = line[3:].strip()
77
83
  content = _read_file_content(filename, spec_dir, zip_archives)
78
84
  if content is not None:
79
85
  samples = samples + "<span style=\"color:green\">" + ansi_to_html(content) + "</span>"
80
86
  else:
81
- samples = samples + "<span style=\"color:green\">Input from file: " + filename + "\n</span>"
87
+ samples = samples + "<span style=\"color:green\">Input from file: " + esc(filename) + "\n</span>"
82
88
  elif line.startswith('IB '):
83
- samples = samples + "<span style=\"color:green\">" + line[3:] + "</span>"
89
+ samples = samples + "<span style=\"color:green\">" + esc(line[3:]) + "</span>"
84
90
  elif line.startswith('I '):
85
- samples = samples + "<span style=\"color:green\">" + line[2:] + "</span>"
91
+ samples = samples + "<span style=\"color:green\">" + esc(line[2:]) + "</span>"
86
92
  elif line.startswith('OF '):
87
93
  filename = line[3:].strip()
88
94
  content = _read_file_content(filename, spec_dir, zip_archives)
89
95
  if content is not None:
90
96
  samples = samples + "<span style=\"color:blue\">" + ansi_to_html(content) + "</span>"
91
97
  else:
92
- samples = samples + "<span style=\"color:blue\">Expected output from file: " + filename + "\n</span>"
98
+ samples = samples + "<span style=\"color:blue\">Expected output from file: " + esc(filename) + "\n</span>"
93
99
  elif line.startswith('OB '):
94
- samples = samples + "<span style=\"color:blue\">" + line[3:] + "</span>"
100
+ samples = samples + "<span style=\"color:blue\">" + esc(line[3:]) + "</span>"
95
101
  elif line.startswith('O '):
96
- samples = samples + "<span style=\"color:blue\">" + line[2:] + "</span>"
102
+ samples = samples + "<span style=\"color:blue\">" + esc(line[2:]) + "</span>"
97
103
  elif line.startswith('X '):
98
- samples = samples + "Expected Exit Code: " + line[2:]
104
+ samples = samples + "Expected Exit Code: " + esc(line[2:])
99
105
  elif line.startswith('EF '):
100
106
  filename = line[3:].strip()
101
107
  content = _read_file_content(filename, spec_dir, zip_archives)
102
108
  if content is not None:
103
109
  samples = samples + "<span style=\"color:Tomato\">" + ansi_to_html(content) + "</span>"
104
110
  else:
105
- samples = samples + "<span style=\"color:Tomato\">Expected error from file: " + filename + "\n</span>"
111
+ samples = samples + "<span style=\"color:Tomato\">Expected error from file: " + esc(filename) + "\n</span>"
106
112
  elif line.startswith('EB '):
107
- samples = samples + "<span style=\"color:Tomato\">" + line[3:] + "</span>"
113
+ samples = samples + "<span style=\"color:Tomato\">" + esc(line[3:]) + "</span>"
108
114
  elif line.startswith('E '):
109
- samples = samples + "<span style=\"color:Tomato\">" + line[2:] + "</span>"
115
+ samples = samples + "<span style=\"color:Tomato\">" + esc(line[2:]) + "</span>"
110
116
  else:
111
117
  continue
112
118
  samples = samples + "</code></pre>"
@@ -140,8 +146,9 @@ def mdToHtml(file_name, files_resolver=None):
140
146
  compile_command = line[2:].strip()
141
147
  if past_crt_hw and line.startswith('Z '):
142
148
  zip_paths.append(line[2:].strip())
143
- if 'EXMPLS ' in line:
144
- numOfSampleTC = int(line[7:])
149
+ exmpls_match = re.search(r'EXMPLS\s+([0-9]+)', line)
150
+ if exmpls_match:
151
+ numOfSampleTC = int(exmpls_match.group(1))
145
152
  text = text + line
146
153
  spec_dir = os.path.dirname(os.path.abspath(file_name))
147
154
 
@@ -158,7 +165,10 @@ def mdToHtml(file_name, files_resolver=None):
158
165
  for zf in zip_archives:
159
166
  zf.close()
160
167
 
161
- assignment = re.sub('EXMPLS [0-9]+', samples, assignment)
168
+ # Use a function replacement so backslash sequences in the sample data
169
+ # (e.g. "\d" or "\1" in expected output) are inserted literally rather
170
+ # than interpreted as regex escapes.
171
+ assignment = re.sub('EXMPLS [0-9]+', lambda m: samples, assignment)
162
172
  if compile_command:
163
173
  assignment = assignment.replace('COMPILE', compile_command)
164
174
  # Handle FILE macros in two passes:
@@ -95,7 +95,7 @@ def files_resolver(filename):
95
95
  if not matches:
96
96
  warn(f"could not find {filename} in {z}")
97
97
  elif len(matches) > 1:
98
- warn("found multiple matches for {filename} in {z}: {matches}")
98
+ warn(f"found multiple matches for {filename} in {z}: {matches}")
99
99
  if matches:
100
100
  zfname = matches[0]
101
101
  with TemporaryDirectory("ce") as tmpdir:
@@ -115,7 +115,9 @@ def files_resolver(filename):
115
115
  @click.option("--verbose/--no-verbose", default=False, show_default=True, help="Verbose actions")
116
116
  @click.option("--group_name", default="Assignments", show_default=True,
117
117
  help="Group name in which assignments needs to be created.")
118
- def create_assignment(dryrun, verbose, course_name, group_name, specname, extra):
118
+ @click.option("--active/--inactive", default=True, show_default=True,
119
+ help="Only look at active courses.")
120
+ def create_assignment(dryrun, verbose, course_name, group_name, specname, extra, active):
119
121
  """
120
122
  Create the assignment in the given course.
121
123
  """
@@ -128,7 +130,7 @@ def create_assignment(dryrun, verbose, course_name, group_name, specname, extra)
128
130
  (canvas, user) = connect_to_canvas()
129
131
 
130
132
  try:
131
- course = get_course(canvas, course_name)
133
+ course = get_course(canvas, course_name, is_active=active)
132
134
  except Exception as e:
133
135
  errorWithException(f'get_course api failed with following error : {e}')
134
136
  else:
@@ -152,16 +154,17 @@ def create_assignment(dryrun, verbose, course_name, group_name, specname, extra)
152
154
  canvas_folder = get_assignment_subfolder(course, codeval_folder, assign_name_early)
153
155
  if extra:
154
156
  upload_assignment_files(canvas_folder, extra)
155
- # find zipfiles in spec
157
+ # find zipfiles in spec; resolve them relative to the spec directory so the
158
+ # command works when run from any working directory
159
+ spec_dir = os.path.dirname(os.path.abspath(specname))
156
160
  with open(specname) as f:
157
161
  for line in f:
158
162
  if line.startswith("Z "):
159
- zipfile = line[2:].strip()
160
- if zipfile not in zip_files:
161
- zip_files.append(zipfile)
163
+ zip_name = os.path.join(spec_dir, line[2:].strip())
164
+ if zip_name not in zip_files:
165
+ zip_files.append(zip_name)
162
166
 
163
167
  # Extract FILE macros and upload local files that exist
164
- spec_dir = os.path.dirname(os.path.abspath(specname))
165
168
  file_macros = extract_file_macros(specname)
166
169
  for filename in file_macros:
167
170
  if filename not in file_dict:
@@ -3,10 +3,10 @@
3
3
  import ast
4
4
  import os
5
5
  import re
6
+ import signal
6
7
  import subprocess
7
8
  import sys
8
9
  import traceback
9
- import threading
10
10
  import time
11
11
 
12
12
  import click
@@ -295,6 +295,22 @@ def _is_function_used(function_name, files, allow_regex_fallback=True):
295
295
  return False
296
296
 
297
297
 
298
+ def _resolve_function_check_files():
299
+ """Derive source files for a CF/NCF tag that gave no explicit filenames.
300
+
301
+ Source files come from the most recent compile command. Commands that don't
302
+ name their sources (e.g. ``make``) yield nothing, in which case the check
303
+ cannot be trusted, so print a visible warning rather than silently passing a
304
+ forbidden-function ban or failing a required-function check for everyone.
305
+ """
306
+ files = _extract_source_files_from_compile(last_compile_command)
307
+ if not files:
308
+ print(f" WARNING: could not determine source files from compile command "
309
+ f"'{last_compile_command}'; the function-usage check is unreliable. "
310
+ f"List the source file(s) explicitly after the function name in the CF/NCF tag.")
311
+ return files
312
+
313
+
298
314
  def check_function(args):
299
315
  """Will be followed by a function name and an optional list of files to check to ensure that
300
316
  the function is used by one of those files.
@@ -323,7 +339,7 @@ def check_function(args):
323
339
  if not files:
324
340
  # No filename provided — derive source files from the compile command and
325
341
  # disable the regex fallback (compiled-artifact check only).
326
- files = _extract_source_files_from_compile(last_compile_command)
342
+ files = _resolve_function_check_files()
327
343
  if _is_function_used(function_name, files, allow_regex_fallback=False):
328
344
  print(f"Used {function_name} PASSED")
329
345
  else:
@@ -457,7 +473,7 @@ def check_not_function(args):
457
473
  if not files:
458
474
  # No filename provided — derive source files from the compile command and
459
475
  # disable the regex fallback (compiled-artifact check only).
460
- files = _extract_source_files_from_compile(last_compile_command)
476
+ files = _resolve_function_check_files()
461
477
  used = _is_function_used(function_name, files, allow_regex_fallback=False)
462
478
  else:
463
479
  used = _is_function_used(function_name, files)
@@ -683,7 +699,6 @@ def check_output_file(output_file):
683
699
  Returns:
684
700
  None
685
701
  """
686
- output_length(os.path.getsize(output_file))
687
702
  with open(output_file, "r") as infile:
688
703
  output_lines = infile.readlines()
689
704
 
@@ -773,7 +788,7 @@ def _post_test_temp_cleanup():
773
788
 
774
789
 
775
790
  def timeout(timeout_sec):
776
- """Specifies the time limit in seconds for a test case to run. Defaults to 20 seconds.
791
+ """Specifies the time limit in seconds for a test case to run. Defaults to 10 seconds.
777
792
 
778
793
  Arguments:
779
794
  timeout_sec: time limit in seconds for a test case to run
@@ -799,7 +814,9 @@ def output_length(length):
799
814
 
800
815
 
801
816
  def exit_code(test_case_exit_code):
802
- """Specifies the expected exit code for a test case. Defaults to zero.
817
+ """Specifies the expected exit code for a test case.
818
+
819
+ If no X tag is given, the exit code is not checked.
803
820
 
804
821
  Arguments:
805
822
  test_case_exit_code: the expected exit code for a test case
@@ -811,48 +828,6 @@ def exit_code(test_case_exit_code):
811
828
  expected_exit_code = int(test_case_exit_code)
812
829
 
813
830
 
814
- def start_server(timeout_sec, kill_timeout_sec, *server_cmd):
815
- """Command containing timeout (wait until server starts), kill timeout (wait to kill the server),
816
- and the command to start a server
817
-
818
- Arguments:
819
- timeout_sec: timeout in seconds to wait for server to start
820
- kill_timeout_sec: timeout in seconds to wait until killing the server
821
- server_cmd: command to run to start the server
822
-
823
- Returns:
824
- None
825
- """
826
-
827
- print(
828
- f'Starting server with command: {" ".join(server_cmd)} and sleeping for: {timeout_sec}. Will kill server '
829
- f'after {kill_timeout_sec} seconds.'
830
- )
831
-
832
- # Send output to compile log in background
833
- with open(get_testing_path("compilelog"), "w") as outfile:
834
- server_popen = subprocess.Popen(
835
- server_cmd, shell=True, stdout=outfile, stderr=outfile, text=True
836
- )
837
-
838
- print(f"Server pid: {server_popen.pid}. Sleeping for {timeout_sec} seconds.")
839
- # Block for timeout_sec so that server can start
840
- time.sleep(float(timeout_sec))
841
-
842
- # Kill the server after the timeout
843
- def kill_server(pid):
844
- print(f"Killing {pid}")
845
- subprocess.Popen(
846
- ["kill", "-9", f"{pid}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
847
- )
848
-
849
- kill_timer = threading.Timer(
850
- float(kill_timeout_sec), kill_server, *[server_popen.pid]
851
- )
852
- kill_timer.daemon = True
853
- kill_timer.start()
854
-
855
-
856
831
  """
857
832
  Here is where the tags are mapped to functions.
858
833
  Any tags that are added or changed must be modified here.
@@ -883,7 +858,6 @@ tag_func_map = {
883
858
  "TO": timeout,
884
859
  "OLEN": output_length,
885
860
  "X": exit_code,
886
- "SS": start_server,
887
861
  "TEMP": register_temp_file,
888
862
  }
889
863
 
@@ -921,8 +895,10 @@ def parse_tags(tags: list[str]):
921
895
  None
922
896
  """
923
897
  # Pattern matches: TAG arguments (arguments required)
924
- # Use single space separator to preserve leading whitespace in values
925
- tag_pattern = r"([A-Z_]+) (.*)"
898
+ # A single space or tab separates the tag from its value; any further
899
+ # whitespace is preserved as part of the value. Accepting a tab here keeps a
900
+ # tab-separated tag (e.g. "T\t./prog") from being silently dropped.
901
+ tag_pattern = r"([A-Z_]+)[ \t](.*)"
926
902
  # Pattern for tag with optional arguments
927
903
  tag_only_pattern = r"([A-Z_]+)\s*$"
928
904
 
@@ -1173,7 +1149,8 @@ def check_test():
1173
1149
  get_testing_path("youroutput"), "w"
1174
1150
  ) as youroutput, open(get_testing_path("yourerror"), "w") as yourerror:
1175
1151
  test_exec = subprocess.Popen(
1176
- test_args, shell=True, stdin=fileinput, stdout=youroutput, stderr=yourerror
1152
+ test_args, shell=True, stdin=fileinput, stdout=youroutput, stderr=yourerror,
1153
+ start_new_session=True,
1177
1154
  )
1178
1155
 
1179
1156
  # Timeout handling
@@ -1183,11 +1160,21 @@ def check_test():
1183
1160
  except subprocess.TimeoutExpired:
1184
1161
  print(f"Took more than {timeout_val} seconds to run. FAIL")
1185
1162
  passed = False
1163
+ # Kill the whole process group so orphaned children (the shell spawned
1164
+ # by shell=True and anything it launched) don't keep running.
1165
+ try:
1166
+ os.killpg(os.getpgid(test_exec.pid), signal.SIGKILL)
1167
+ except (ProcessLookupError, PermissionError):
1168
+ test_exec.kill()
1169
+ test_exec.communicate()
1186
1170
 
1187
1171
  # Find files touched by student's program
1188
1172
  touched_files = _find_touched_files(pre_run_timestamp)
1189
1173
 
1190
- # Difflog handling
1174
+ # Difflog handling. Pass/fail is decided by the diff exit status (0 == files
1175
+ # identical), NOT by how much of the diff we render: OLEN/OF control only the
1176
+ # amount of rendered text and must never be able to hide a real mismatch.
1177
+ output_differs = False
1191
1178
  with open(get_testing_path("difflog"), "w") as outfile:
1192
1179
  diff_popen = subprocess.Popen(
1193
1180
  ["diff", "-U1", "-a",
@@ -1195,6 +1182,8 @@ def check_test():
1195
1182
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
1196
1183
  )
1197
1184
  raw_output, _ = diff_popen.communicate()
1185
+ if diff_popen.returncode != 0:
1186
+ output_differs = True
1198
1187
  outfile.write(_render_diff_output(raw_output[:output_length_limit]))
1199
1188
 
1200
1189
  # Append to difflog second time around
@@ -1205,14 +1194,17 @@ def check_test():
1205
1194
  stdout=subprocess.PIPE, stderr=subprocess.PIPE,
1206
1195
  )
1207
1196
  raw_output, _ = diff_popen.communicate()
1197
+ if diff_popen.returncode != 0:
1198
+ output_differs = True
1208
1199
  outfile.write(_render_diff_output(raw_output[:output_length_limit]))
1209
1200
 
1210
1201
  # Now read all the lines to accumulate both diffs
1211
1202
  with open(get_testing_path("difflog"), "r") as infile:
1212
1203
  diff_lines = infile.readlines()
1213
1204
 
1214
- if len(diff_lines):
1205
+ if output_differs:
1215
1206
  passed = False
1207
+ if len(diff_lines):
1216
1208
  parse_diff(diff_lines, TESTING_DIR)
1217
1209
 
1218
1210
  # Exit code handling
@@ -1354,7 +1346,9 @@ def run_evaluation(codeval_file):
1354
1346
  continue
1355
1347
  if in_crt_hw_block:
1356
1348
  continue
1357
- parts = testcase.split(" ", 1)
1349
+ parts = stripped.split(None, 1)
1350
+ if not parts:
1351
+ continue
1358
1352
  tag = parts[0]
1359
1353
  if tag == "T" or tag == "HT" or tag == "TCMD":
1360
1354
  test_case_total += 1
@@ -5,7 +5,7 @@ import subprocess
5
5
 
6
6
  import requests
7
7
  import zipfile
8
- from assignment_codeval.commons import debug, error
8
+ from assignment_codeval.commons import debug, errorWithException
9
9
 
10
10
 
11
11
  def download_attachment(directory, attachment):
@@ -13,26 +13,31 @@ def download_attachment(directory, attachment):
13
13
  curPath = os.getcwd()
14
14
  directory = os.path.join(curPath, directory)
15
15
 
16
- fname = attachment['display_name']
17
- prefix = os.path.splitext(fname)[0]
18
- suffix = os.path.splitext(fname)[1]
16
+ # display_name is attacker-controlled; collapse it to a bare filename so it
17
+ # cannot escape `directory` via path separators or "..".
18
+ fname = os.path.basename(attachment['display_name'])
19
+ if fname in ('', '.', '..'):
20
+ errorWithException(f"refusing to download attachment with unsafe name: {attachment['display_name']!r}")
19
21
  durl = attachment['url']
22
+ dest = os.path.join(directory, fname)
20
23
  with requests.get(durl) as response:
21
24
  if response.status_code != 200:
22
- error(f'error {response.status_code} fetching {durl}')
23
- with open(os.path.join(directory, f"{prefix}{suffix}"), "wb") as fd:
24
- for chunk in response.iter_content():
25
+ # Don't write the error page body as if it were the attachment.
26
+ errorWithException(f'error {response.status_code} fetching {durl}')
27
+ with open(dest, "wb") as fd:
28
+ for chunk in response.iter_content(chunk_size=8192):
25
29
  fd.write(chunk)
26
30
 
27
- return os.path.join(directory, fname)
31
+ return dest
28
32
 
29
33
 
30
34
  def unzip(filepath, dir, delete=False):
31
35
  with zipfile.ZipFile(filepath) as file:
32
36
  for zi in file.infolist():
33
- file.extract(zi.filename, path=dir)
37
+ # extract() sanitizes "../" and absolute members and returns the
38
+ # real path it wrote; stat/chmod that, not the raw archive name.
39
+ fname = file.extract(zi.filename, path=dir)
34
40
  debug(f"extracting {zi.filename}")
35
- fname = os.path.join(dir, zi.filename)
36
41
  s = os.stat(fname)
37
42
  # the user executable bit is set
38
43
  perms = (s.st_mode | (zi.external_attr >> 16)) & 0o777
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import re
3
+ import shutil
3
4
  import subprocess
4
5
  from time import sleep
5
6
 
@@ -10,6 +11,27 @@ from assignment_codeval.commons import error, info
10
11
  HEX_DIGITS = "0123456789abcdefABCDEF"
11
12
 
12
13
 
14
+ def _is_safe_repo_url(url):
15
+ """Reject repo URLs that git would treat as an option or a code-executing transport.
16
+
17
+ A clone URL is partly derived from a student-controlled GitHub id, so a value
18
+ beginning with '-' (argument injection) or using git's ext::/file::/fd:: transport
19
+ helpers (arbitrary command execution) must never reach `git clone`.
20
+ """
21
+ if not url or url.startswith('-'):
22
+ return False
23
+ authority = url.split('/', 1)[0]
24
+ # ext::sh -c ... and friends use a "<helper>::" prefix before the first slash.
25
+ if '::' in authority:
26
+ return False
27
+ # Require a recognized remote form: scheme://... or scp-like git@host:path.
28
+ if re.match(r'^(https?|git|ssh)://', url, re.IGNORECASE):
29
+ return True
30
+ if re.match(r'^[\w.-]+@[\w.-]+:', url):
31
+ return True
32
+ return False
33
+
34
+
13
35
  def _read_metadata(ssid_dir):
14
36
  """Read metadata.txt and return a dict of key=value pairs."""
15
37
  metadata_path = os.path.join(ssid_dir, "metadata.txt")
@@ -24,6 +46,22 @@ def _read_metadata(ssid_dir):
24
46
  return metadata
25
47
 
26
48
 
49
+ def _read_desired_commit(content_path):
50
+ """Return the validated (hex) commit hash from content.txt, or None.
51
+
52
+ content.txt holds the commit the student submitted, possibly wrapped in HTML
53
+ tags/entities from Canvas; strip those and accept it only if it is all hex.
54
+ """
55
+ if not os.path.exists(content_path):
56
+ return None
57
+ with open(content_path, "r") as cfd:
58
+ content = re.sub(r"<.*?>", "", cfd.readline().strip()).strip()
59
+ content = re.sub(r"&[a-z]+;", "", content).strip()
60
+ if content and all(c in HEX_DIGITS for c in content):
61
+ return content
62
+ return None
63
+
64
+
27
65
  @click.command()
28
66
  @click.argument("target_dir", metavar="SUBMISSIONS_DIR", default='./submissions')
29
67
  @click.option("--clone-delay",
@@ -65,8 +103,21 @@ def _setup_repos_for_assignment(assignment_path, clone_delay):
65
103
  submission_path = os.path.join(ssid_dir, "submission")
66
104
 
67
105
  if os.path.exists(submission_path):
68
- info(f"skipping {ssid_dir}, repo already exists at {submission_path}")
69
- continue
106
+ # A clone already exists. If the student resubmitted a new commit,
107
+ # the old checkout would grade the wrong attempt — re-clone in that
108
+ # case. If we can't determine the desired commit, leave it as-is.
109
+ desired_commit = _read_desired_commit(content_path)
110
+ recorded_commit = None
111
+ if os.path.exists(success_path):
112
+ with open(success_path, "r") as sfd:
113
+ recorded_commit = sfd.readline().strip()
114
+ if not desired_commit or recorded_commit == desired_commit:
115
+ info(f"skipping {ssid_dir}, repo already at {recorded_commit or 'existing checkout'}")
116
+ continue
117
+ info(f"re-cloning {ssid_dir}: checked-out {recorded_commit} != submitted {desired_commit}")
118
+ # Guard against removing anything but the clone directory.
119
+ if os.path.basename(submission_path.rstrip("/")) == "submission":
120
+ shutil.rmtree(submission_path)
70
121
 
71
122
  metadata = _read_metadata(ssid_dir)
72
123
  repo_url = metadata.get('github_repo', '')
@@ -76,20 +127,22 @@ def _setup_repos_for_assignment(assignment_path, clone_delay):
76
127
 
77
128
  click.echo(f"Setting up repo for {ssid_dir}")
78
129
 
130
+ if not _is_safe_repo_url(repo_url):
131
+ error(f"❌ refusing to clone unsafe repo url for {ssid}: {repo_url!r}")
132
+ with open(result_path, "w") as fd:
133
+ print(f"❌ refusing to clone unsafe repo url: {repo_url}", file=fd)
134
+ continue
135
+
79
136
  with open(result_path, "w") as fd:
80
137
  # Read commit hash from content.txt
81
- content = None
82
- if os.path.exists(content_path):
83
- with open(content_path, "r") as cfd:
84
- content = re.sub(r"<.*?>", "", cfd.readline().strip()).strip()
85
- content = re.sub(r"&[a-z]+;", "", content).strip()
86
- if not content or not all(c in HEX_DIGITS for c in content):
87
- print(f"❌ an invalid git digest was found: {content}", file=fd)
138
+ content = _read_desired_commit(content_path)
139
+ if not content:
140
+ print(f"❌ an invalid git digest was found in {content_path}", file=fd)
88
141
  continue
89
142
 
90
143
  click.echo(f"Cloning {repo_url} to {ssid_dir}")
91
144
  print(f"cloning {repo_url}", file=fd)
92
- rc = subprocess.run(['git', 'clone', repo_url, submission_path], stdout=fd, stderr=subprocess.STDOUT)
145
+ rc = subprocess.run(['git', 'clone', '--', repo_url, submission_path], stdout=fd, stderr=subprocess.STDOUT)
93
146
  if rc.returncode != 0:
94
147
  error(f"❌ error {rc.returncode} connecting to github repo for {ssid} using {repo_url}")
95
148
  continue
@@ -38,7 +38,8 @@ def format_comment_preview(comment: str, prefix: str) -> str:
38
38
  Returns:
39
39
  Formatted preview string
40
40
  """
41
- # Strip the prefix
41
+ # Strip the prefix (stored comments use the rstripped prefix + newline)
42
+ prefix = prefix.rstrip()
42
43
  text = comment[len(prefix):] if comment.startswith(prefix) else comment
43
44
  lines = text.strip().split('\n')
44
45
 
@@ -183,7 +184,10 @@ def recent_comments(course_name, active, time_period, codeval_prefix, verbose, s
183
184
  codeval_comments = []
184
185
  for comment in comments:
185
186
  comment_text = comment.get("comment", "")
186
- if not comment_text.startswith(codeval_prefix):
187
+ # Stored comments begin with the rstripped prefix + newline
188
+ # (see upload_submission_comments), so match without the
189
+ # trailing space the default prefix carries.
190
+ if not comment_text.startswith(codeval_prefix.rstrip()):
187
191
  continue
188
192
 
189
193
  created_at_str = comment.get("createdAt", "")
@@ -3,6 +3,7 @@ import re
3
3
  import shutil
4
4
  import subprocess
5
5
  import time
6
+ import unicodedata
6
7
  from configparser import ConfigParser
7
8
  from datetime import datetime, timedelta, timezone
8
9
  from zoneinfo import ZoneInfo
@@ -16,6 +17,43 @@ import requests
16
17
  from assignment_codeval.canvas_utils import connect_to_canvas, get_course, get_courses, get_assignment
17
18
  from assignment_codeval.commons import debug, error, info, warn, despace
18
19
 
20
+ # Bookkeeping files the tool writes into each student directory. A student must
21
+ # never be able to clobber these via an attachment filename — in particular
22
+ # SUBSTITUTIONS.txt is applied to their own grading comment, which would let a
23
+ # student forge their grade.
24
+ _RESERVED_SUBMISSION_FILES = {
25
+ "metadata.txt", "content.txt", "comments.txt", "comments.txt.sent",
26
+ "SUBSTITUTIONS.txt", "codeval_path.txt", "codeval.txt", "last-comment.txt",
27
+ "results.html", "gh_success.txt",
28
+ }
29
+
30
+
31
+ def _is_within_tree(tree_root, path):
32
+ """True if ``path``'s parent directory resolves to a location inside ``tree_root``.
33
+
34
+ Resolves symlinks so a student-planted symlink in a cloned repo cannot
35
+ redirect our file writes outside the submission directory.
36
+ """
37
+ real_root = os.path.realpath(tree_root)
38
+ real_parent = os.path.realpath(os.path.dirname(path))
39
+ return real_parent == real_root or real_parent.startswith(real_root + os.sep)
40
+
41
+
42
+ def _safe_copy_into_tree(src, dst, tree_root):
43
+ """Copy ``src`` to ``dst``, refusing to follow symlinks out of ``tree_root``.
44
+
45
+ Returns True if the file was copied, False if the destination was rejected
46
+ as escaping the tree (possible symlink attack).
47
+ """
48
+ if not _is_within_tree(tree_root, dst):
49
+ error(f"refusing to write {dst}: resolves outside the submission tree")
50
+ return False
51
+ # Don't write *through* a student-planted symlink at the final path.
52
+ if os.path.islink(dst):
53
+ os.unlink(dst)
54
+ shutil.copy(src, dst)
55
+ return True
56
+
19
57
 
20
58
  def _parse_codeval_test_info(codeval_file):
21
59
  """Parse a codeval file and return a mapping from test case number to test metadata.
@@ -140,12 +178,31 @@ def find_codeval_file(codeval_dir, assignment_name):
140
178
  return None
141
179
 
142
180
 
143
- def get_github_repo_url(course, user_id, config_parser, github_field="github"):
181
+ def github_safe_name(name):
182
+ """a person's name as a valid github repo-name fragment.
183
+
184
+ accented letters lose their accents, anything github won't take in a
185
+ repo name becomes "-", runs collapse, and the edges are trimmed.
186
+ Matches the repo naming used by gh-class-sak.
187
+ """
188
+ ascii_name = unicodedata.normalize("NFKD", name)
189
+ ascii_name = "".join(ch for ch in ascii_name if not unicodedata.combining(ch))
190
+ safe = "".join(ch if (ch.isascii() and ch.isalnum()) or ch in "._-" else "-"
191
+ for ch in ascii_name)
192
+ while "--" in safe:
193
+ safe = safe.replace("--", "-")
194
+ return safe.strip("-._") or "x"
195
+
196
+
197
+ def get_github_repo_url(course, user_id, config_parser, github_field="github", from_name=None):
144
198
  """
145
199
  Get the GitHub repo URL for a user in a course.
146
200
 
147
201
  Returns the repo URL string if found, or None if GitHub is not configured
148
202
  for this course or the user doesn't have a GitHub link in their profile.
203
+
204
+ If from_name is given, the repo URL is built from that name using
205
+ gh-class-sak's repo naming instead of the profile's GitHub link.
149
206
  """
150
207
  gh_key = course.name.replace(":", "").replace("=", "")
151
208
  if 'GITHUB' not in config_parser or gh_key not in config_parser['GITHUB']:
@@ -153,6 +210,9 @@ def get_github_repo_url(course, user_id, config_parser, github_field="github"):
153
210
 
154
211
  gh_repo_prefix = config_parser['GITHUB'][gh_key]
155
212
 
213
+ if from_name is not None:
214
+ return f"{gh_repo_prefix}-{github_safe_name(from_name)}.git"
215
+
156
216
  try:
157
217
  user = course.get_user(user_id)
158
218
  profile = user.get_profile(include=["links", "link"])
@@ -428,9 +488,14 @@ def upload_submission_comments(submissions_dir, codeval_prefix, delete):
428
488
  substitutions = _parse_substitutions_file(subs_file)
429
489
  comment = _apply_substitutions(comment, substitutions)
430
490
  if delete:
431
- all_comments = sorted(submission.submission_comments, key=lambda c: c['created_at'])
432
- if all_comments:
433
- last_comment_id = all_comments[-1]["id"]
491
+ # Only delete a previous codeval comment, never a
492
+ # student's or TA's comment that happens to be newest.
493
+ codeval_comments = sorted(
494
+ (c for c in submission.submission_comments
495
+ if c.get('comment', '').startswith(codeval_prefix.rstrip())),
496
+ key=lambda c: c['created_at'])
497
+ if codeval_comments:
498
+ last_comment_id = codeval_comments[-1]["id"]
434
499
  delete_submission_comment(canvas, course.id, assignment.id, student_id, last_comment_id)
435
500
  canvas_url, canvas_token = _get_canvas_config()
436
501
  requests.put(
@@ -441,10 +506,12 @@ def upload_submission_comments(submissions_dir, codeval_prefix, delete):
441
506
  'comment[file_ids][]': file_id,
442
507
  }
443
508
  ).raise_for_status()
509
+ # Only mark as sent once we have actually uploaded; otherwise
510
+ # a transient "no submission" would skip this student forever.
511
+ with open(f"{dirpath}/comments.txt.sent", "w") as fd:
512
+ fd.write(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
444
513
  else:
445
514
  warn(f"no submission found for {student_id} in {course_name}: {assignment_name}")
446
- with open(f"{dirpath}/comments.txt.sent", "w") as fd:
447
- fd.write(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
448
515
 
449
516
 
450
517
  @click.command()
@@ -472,8 +539,10 @@ def evaluate_submissions(codeval_dir, submissions_dir):
472
539
  raw_command = parser["RUN"]["command"]
473
540
  if not raw_command:
474
541
  warn(f"commands section under [RUN] in {parser.config_file} is empty")
475
- for dirpath, dirnames, filenames in os.walk(submissions_dir):
476
- match = re.match(fr'^{submissions_dir}/([^/]+)/([^/]+)/([^/]+)$', dirpath)
542
+ clean_submissions_dir = submissions_dir.rstrip('/').replace('\\', '/')
543
+ for dirpath, dirnames, filenames in os.walk(clean_submissions_dir):
544
+ dirpath = dirpath.replace('\\', '/')
545
+ match = re.match(fr'^{re.escape(clean_submissions_dir)}/([^/]+)/([^/]+)/([^/]+)$', dirpath)
477
546
  if not match:
478
547
  continue
479
548
 
@@ -496,17 +565,27 @@ def evaluate_submissions(codeval_dir, submissions_dir):
496
565
  has_cd_tag = False
497
566
  zip_files = []
498
567
  move_to_next_submission = False
568
+ in_crt_hw_block = False
499
569
  with open(codeval_file, "r") as fd:
500
570
  for line in fd:
501
571
  line = line.strip()
502
- if line.startswith("CTO"):
572
+ # Skip the Canvas description markdown; it is prose, not tags
573
+ # (e.g. "Zip your solution" must not be read as a Z tag).
574
+ if line.startswith("CRT_HW"):
575
+ in_crt_hw_block = not in_crt_hw_block
576
+ continue
577
+ if in_crt_hw_block or not line:
578
+ continue
579
+ parts = line.split(None, 1)
580
+ tag = parts[0]
581
+ if tag == "CTO":
503
582
  try:
504
- compile_timeout = int(line.split(None, 1)[1])
505
- except Exception:
583
+ compile_timeout = int(parts[1])
584
+ except (IndexError, ValueError):
506
585
  warn(f"could not parse compile timeout from {line}, using default {compile_timeout}")
507
- if line.startswith("CD"):
586
+ if tag == "CD" and len(parts) > 1:
508
587
  has_cd_tag = True
509
- cd_dir = line.split()[1].strip()
588
+ cd_dir = parts[1].split()[0].strip()
510
589
  if cd_dir == "GITHUB_DIRECTORY":
511
590
  cd_dir = assignment_name
512
591
  assignment_working_dir = os.path.normpath(
@@ -515,8 +594,8 @@ def evaluate_submissions(codeval_dir, submissions_dir):
515
594
  out = f"{assignment_working_dir} does not exist or is not a directory\n".encode('utf-8')
516
595
  move_to_next_submission = True
517
596
  break
518
- if line.startswith("Z"):
519
- zip_files.append(line.split(None, 1)[1])
597
+ if tag == "Z" and len(parts) > 1:
598
+ zip_files.append(parts[1].strip())
520
599
 
521
600
  # If no CD tag and this is a GitHub submission (has .git), use assignment name as working dir
522
601
  if not has_cd_tag and os.path.exists(os.path.join(submission_dir, ".git")):
@@ -549,8 +628,12 @@ def evaluate_submissions(codeval_dir, submissions_dir):
549
628
  full_assignment_working_dir = os.path.join(submission_link, assignment_working_dir)
550
629
  if not os.path.isdir(full_assignment_working_dir):
551
630
  out = b"no submission directory found"
631
+ elif not _safe_copy_into_tree(
632
+ codeval_file,
633
+ os.path.join(full_assignment_working_dir, "codeval.txt"),
634
+ submission_dir):
635
+ out = b"refusing to write codeval.txt into the submission (possible symlink attack)"
552
636
  else:
553
- shutil.copy(codeval_file, os.path.join(full_assignment_working_dir, "codeval.txt"))
554
637
  codeval_source_dir = os.path.dirname(codeval_file)
555
638
  with open(codeval_file, "r") as cf:
556
639
  for cf_line in cf:
@@ -559,7 +642,10 @@ def evaluate_submissions(codeval_dir, submissions_dir):
559
642
  ref_file = parts[1].strip()
560
643
  src = os.path.join(codeval_source_dir, ref_file)
561
644
  if os.path.isfile(src):
562
- shutil.copy(src, os.path.join(full_assignment_working_dir, ref_file))
645
+ _safe_copy_into_tree(
646
+ src,
647
+ os.path.join(full_assignment_working_dir, ref_file),
648
+ submission_dir)
563
649
 
564
650
  command = command.replace("SUBMISSIONS", full_assignment_working_dir)
565
651
  info(f"command to execute: {command}")
@@ -579,7 +665,9 @@ def evaluate_submissions(codeval_dir, submissions_dir):
579
665
  info("finished executing docker")
580
666
 
581
667
  info("writing results")
582
- with open(f"{dirpath}/comments.txt", "ab") as fd:
668
+ # Truncate, don't append: re-running evaluation must replace the prior
669
+ # result for this submission, not double it up.
670
+ with open(f"{dirpath}/comments.txt", "wb") as fd:
583
671
  fd.write(out)
584
672
  info("continuing")
585
673
 
@@ -599,8 +687,11 @@ def evaluate_submissions(codeval_dir, submissions_dir):
599
687
  @click.option("--codeval-prefix", help="prefix for codeval comments", default="codeval: ", show_default=True)
600
688
  @click.option("--include-empty", is_flag=True, help="include empty submissions")
601
689
  @click.option("--for-name", help="only download submissions for this student name")
690
+ @click.option("--repo-from-name", is_flag=True,
691
+ help="construct the github repo url from the student name (gh-class-sak naming) "
692
+ "instead of the github link in the canvas profile")
602
693
  def download_submissions(course_name, assignment_name, active, until_window, target_dir, include_commented,
603
- codeval_prefix, include_empty, uncommented_for, for_name):
694
+ codeval_prefix, include_empty, uncommented_for, for_name, repo_from_name):
604
695
  """
605
696
  Download submissions for a given assignment in a course from Canvas.
606
697
 
@@ -656,7 +747,7 @@ def download_submissions(course_name, assignment_name, active, until_window, tar
656
747
  info(f"downloading submissions for {course.name}: {assignment.name}")
657
748
  _download_assignment_submissions(
658
749
  canvas, course, assignment, target_dir, include_commented, codeval_prefix,
659
- include_empty, uncommented_for, for_name
750
+ include_empty, uncommented_for, for_name, repo_from_name
660
751
  )
661
752
  return
662
753
 
@@ -664,12 +755,12 @@ def download_submissions(course_name, assignment_name, active, until_window, tar
664
755
  assignment = get_assignment(course, assignment_name)
665
756
  _download_assignment_submissions(
666
757
  canvas, course, assignment, target_dir, include_commented, codeval_prefix,
667
- include_empty, uncommented_for, for_name
758
+ include_empty, uncommented_for, for_name, repo_from_name
668
759
  )
669
760
 
670
761
 
671
762
  def _download_assignment_submissions(canvas, course, assignment, target_dir, include_commented, codeval_prefix,
672
- include_empty, uncommented_for, for_name):
763
+ include_empty, uncommented_for, for_name, repo_from_name=False):
673
764
  """Download submissions for a single assignment."""
674
765
  submission_dir = os.path.join(target_dir, despace(course.name), despace(assignment.name))
675
766
  os.makedirs(submission_dir, exist_ok=True)
@@ -716,7 +807,8 @@ def _download_assignment_submissions(canvas, course, assignment, target_dir, inc
716
807
  os.makedirs(student_submission_dir, exist_ok=True)
717
808
 
718
809
  metapath = os.path.join(student_submission_dir, "metadata.txt")
719
- github_repo = get_github_repo_url(course, submission.user_id, parser)
810
+ github_repo = get_github_repo_url(course, submission.user_id, parser,
811
+ from_name=student_name if repo_from_name else None)
720
812
  with open(metapath, "w") as fd:
721
813
  print(f"""id={student_id}
722
814
  name={student_name}
@@ -748,11 +840,17 @@ github_repo={github_repo or ''}""", file=fd)
748
840
 
749
841
  if hasattr(submission, "attachments"):
750
842
  for attachment in submission.attachments:
751
- fname = attachment.filename
843
+ # attachment.filename is student-controlled: collapse to a bare
844
+ # name so it can't traverse out of the dir, and never let it
845
+ # overwrite one of the tool's control files.
846
+ fname = os.path.basename(attachment.filename)
847
+ if fname in ('', '.', '..') or fname in _RESERVED_SUBMISSION_FILES:
848
+ warn(f"skipping attachment with reserved/unsafe name {attachment.filename!r} "
849
+ f"for student {student_id}")
850
+ continue
752
851
  filepath = os.path.join(student_submission_dir, fname)
753
852
  attachment.download(filepath)
754
-
755
- debug(f"Downloaded submission for student {student_id} to {filepath}")
853
+ debug(f"Downloaded attachment {fname} for student {student_id} to {filepath}")
756
854
 
757
855
  return submission_dir
758
856
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assignment-codeval
3
- Version: 0.0.31
3
+ Version: 0.0.32
4
4
  Summary: CodEval for evaluating programming assignments
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -131,13 +131,13 @@ Codeval files (`.codeval` extension) define how to build, run, and test student
131
131
  | Tag | Meaning | Function |
132
132
  |---|---|---|
133
133
  | C | Compile Code | Specifies the command to compile the submission code |
134
- | CTO | Compile Timeout | Timeout in seconds for the compile command to run |
134
+ | CTO | Compile Timeout | Timeout in seconds for the whole evaluation (compile **and** all test cases) to run |
135
135
  | T/HT | Test Case | Command to run to test the submission (HT = hidden test) |
136
136
  | I/IB/IF | Supply Input | Input for a test case. I adds a newline, IB does not, IF reads from a file |
137
137
  | O/OB/OF | Check Output | Expected output. O adds a newline, OB does not, OF reads from a file |
138
138
  | E/EB | Check Error | Expected error output. E adds a newline, EB does not |
139
- | TO | Timeout | Time limit in seconds for a test case (default: 20) |
140
- | X | Exit Code | Expected exit code for a test case (default: 0) |
139
+ | TO | Timeout | Time limit in seconds for a test case (default: 10) |
140
+ | X | Exit Code | Expected exit code for a test case (not checked unless X is specified) |
141
141
  | TEMP | Temp File | Registers a file to be deleted before and after the next test run |
142
142
  | CF | Check Function | Checks that a function is used in the submission |
143
143
  | NCF | Check Not Function | Checks that a function is **not** used |
@@ -145,7 +145,6 @@ Codeval files (`.codeval` extension) define how to build, run, and test student
145
145
  | PRINT | Print Label | Prints a label/message to stdout |
146
146
  | CMP | Compare | Compares two files |
147
147
  | Z | Download Zip | Zip files to download from Canvas for test cases |
148
- | SS | Start Server | Starts a server with timeout and kill-timeout settings |
149
148
 
150
149
  #### Assignment Description Macros
151
150
 
@@ -15,7 +15,6 @@ src/assignment_codeval/github_connect.py
15
15
  src/assignment_codeval/install_assignment.py
16
16
  src/assignment_codeval/recent_comments.py
17
17
  src/assignment_codeval/submissions.py
18
- src/assignment_codeval/t.html
19
18
  src/assignment_codeval/test_template.html
20
19
  src/assignment_codeval.egg-info/PKG-INFO
21
20
  src/assignment_codeval.egg-info/SOURCES.txt
@@ -1,2 +0,0 @@
1
- <a href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Red dot">hello</a>
2
-