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.
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/PKG-INFO +4 -5
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/README.md +3 -4
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/pyproject.toml +1 -1
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/ai_benchmark.py +12 -3
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/convertMD2Html.py +26 -16
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/create_assignment.py +11 -8
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/evaluate.py +49 -55
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/file_utils.py +15 -10
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/github_connect.py +63 -10
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/recent_comments.py +6 -2
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/submissions.py +124 -26
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/PKG-INFO +4 -5
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/SOURCES.txt +0 -1
- assignment_codeval-0.0.31/src/assignment_codeval/t.html +0 -2
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/setup.cfg +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/__init__.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/canvas_utils.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/check_grading.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/cli.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/commons.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/export_tests.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/install_assignment.py +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/test_template.html +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
- {assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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:
|
|
140
|
-
| X | Exit Code | Expected exit code for a test case (
|
|
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
|
|
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:
|
|
119
|
-
| X | Exit Code | Expected exit code for a test case (
|
|
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
|
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/ai_benchmark.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
238
|
+
**token_kwargs,
|
|
230
239
|
)
|
|
231
240
|
|
|
232
241
|
return response.choices[0].message.content
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/convertMD2Html.py
RENAMED
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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:
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/create_assignment.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
if
|
|
161
|
-
zip_files.append(
|
|
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 =
|
|
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 =
|
|
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
|
|
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.
|
|
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
|
-
#
|
|
925
|
-
|
|
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
|
|
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 =
|
|
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
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/file_utils.py
RENAMED
|
@@ -5,7 +5,7 @@ import subprocess
|
|
|
5
5
|
|
|
6
6
|
import requests
|
|
7
7
|
import zipfile
|
|
8
|
-
from assignment_codeval.commons import debug,
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/github_connect.py
RENAMED
|
@@ -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
|
-
|
|
69
|
-
|
|
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 =
|
|
82
|
-
if
|
|
83
|
-
|
|
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
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/recent_comments.py
RENAMED
|
@@ -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
|
-
|
|
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", "")
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/submissions.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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(
|
|
505
|
-
except
|
|
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
|
|
586
|
+
if tag == "CD" and len(parts) > 1:
|
|
508
587
|
has_cd_tag = True
|
|
509
|
-
cd_dir =
|
|
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
|
|
519
|
-
zip_files.append(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assignment-codeval
|
|
3
|
-
Version: 0.0.
|
|
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
|
|
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:
|
|
140
|
-
| X | Exit Code | Expected exit code for a test case (
|
|
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
|
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/canvas_utils.py
RENAMED
|
File without changes
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/check_grading.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/export_tests.py
RENAMED
|
File without changes
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/install_assignment.py
RENAMED
|
File without changes
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval/test_template.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{assignment_codeval-0.0.31 → assignment_codeval-0.0.32}/src/assignment_codeval.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|