assignment-codeval 0.0.14__tar.gz → 0.0.16__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 (26) hide show
  1. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/PKG-INFO +1 -1
  2. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/pyproject.toml +1 -1
  3. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/cli.py +4 -1
  4. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/github_connect.py +38 -5
  5. assignment_codeval-0.0.16/src/assignment_codeval/install_assignment.py +143 -0
  6. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/submissions.py +151 -19
  7. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/PKG-INFO +1 -1
  8. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/SOURCES.txt +4 -1
  9. assignment_codeval-0.0.16/tests/test_evaluate_submissions.py +158 -0
  10. assignment_codeval-0.0.16/tests/test_install_assignment.py +183 -0
  11. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/README.md +0 -0
  12. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/setup.cfg +0 -0
  13. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/__init__.py +0 -0
  14. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/ai_benchmark.py +0 -0
  15. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/canvas_utils.py +0 -0
  16. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/commons.py +0 -0
  17. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/convertMD2Html.py +0 -0
  18. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/create_assignment.py +0 -0
  19. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/evaluate.py +0 -0
  20. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval/file_utils.py +0 -0
  21. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
  22. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
  23. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/requires.txt +0 -0
  24. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/top_level.txt +0 -0
  25. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/tests/test_codeval.py +0 -0
  26. {assignment_codeval-0.0.14 → assignment_codeval-0.0.16}/tests/test_create_assignment.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assignment-codeval
3
- Version: 0.0.14
3
+ Version: 0.0.16
4
4
  Summary: CodEval for evaluating programming assignments
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assignment-codeval"
7
- version = "0.0.14"
7
+ version = "0.0.16"
8
8
  description = "CodEval for evaluating programming assignments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,7 +4,8 @@ from assignment_codeval.ai_benchmark import get_benchmark_command
4
4
  from assignment_codeval.create_assignment import create_assignment
5
5
  from assignment_codeval.evaluate import run_evaluation
6
6
  from assignment_codeval.github_connect import github_setup_repo
7
- from assignment_codeval.submissions import download_submissions, upload_submission_comments, evaluate_submissions
7
+ from assignment_codeval.install_assignment import install_assignment
8
+ from assignment_codeval.submissions import download_submissions, upload_submission_comments, evaluate_submissions, list_codeval_assignments
8
9
 
9
10
 
10
11
  @click.group()
@@ -17,6 +18,8 @@ cli.add_command(upload_submission_comments)
17
18
  cli.add_command(github_setup_repo)
18
19
  cli.add_command(evaluate_submissions)
19
20
  cli.add_command(create_assignment)
21
+ cli.add_command(list_codeval_assignments)
22
+ cli.add_command(install_assignment)
20
23
  cli.add_command(get_benchmark_command())
21
24
 
22
25
  if __name__ == "__main__":
@@ -1,4 +1,4 @@
1
- import os.path
1
+ import os
2
2
  import re
3
3
  import subprocess
4
4
  from configparser import ConfigParser
@@ -13,8 +13,8 @@ HEX_DIGITS = "0123456789abcdefABCDEF"
13
13
 
14
14
 
15
15
  @click.command()
16
- @click.argument("course_name", metavar="COURSE")
17
- @click.argument("assignment_name", metavar="ASSIGNMENT")
16
+ @click.argument("course_name", metavar="COURSE", required=False)
17
+ @click.argument("assignment_name", metavar="ASSIGNMENT", required=False)
18
18
  @click.option("--all-repos", is_flag=True,
19
19
  help="download all repositories, even if they don't have a valid commit hash")
20
20
  @click.option("--target-dir", help="directory to download submissions to", default='./submissions', show_default=True)
@@ -28,14 +28,47 @@ def github_setup_repo(course_name, assignment_name, target_dir, github_field, al
28
28
 
29
29
  COURSE can be a unique substring of the actual course name.
30
30
 
31
+ If COURSE and ASSIGNMENT are not specified, will process all course/assignment
32
+ subdirectories found in the submissions directory.
31
33
  """
32
34
  canvas, user = connect_to_canvas()
33
35
  parser = ConfigParser()
34
36
  config_file = click.get_app_dir("codeval.ini")
35
37
  parser.read(config_file)
36
38
  parser.config_file = config_file
37
- course = get_course(canvas, course_name, True)
38
- assignment = get_assignment(course, assignment_name)
39
+
40
+ if course_name and assignment_name:
41
+ # Explicit course and assignment specified
42
+ course = get_course(canvas, course_name, True)
43
+ assignment = get_assignment(course, assignment_name)
44
+ _setup_repo_for_assignment(
45
+ canvas, parser, course, assignment, target_dir, github_field, all_repos, clone_delay
46
+ )
47
+ elif course_name or assignment_name:
48
+ raise click.UsageError("Both COURSE and ASSIGNMENT must be specified, or neither")
49
+ else:
50
+ # Scan submissions directory for course/assignment subdirectories
51
+ if not os.path.isdir(target_dir):
52
+ error(f"submissions directory {target_dir} does not exist")
53
+ return
54
+ for course_dir in sorted(os.listdir(target_dir)):
55
+ course_path = os.path.join(target_dir, course_dir)
56
+ if not os.path.isdir(course_path):
57
+ continue
58
+ for assignment_dir in sorted(os.listdir(course_path)):
59
+ assignment_path = os.path.join(course_path, assignment_dir)
60
+ if not os.path.isdir(assignment_path):
61
+ continue
62
+ info(f"processing {course_dir}/{assignment_dir}")
63
+ course = get_course(canvas, course_dir, True)
64
+ assignment = get_assignment(course, assignment_dir)
65
+ _setup_repo_for_assignment(
66
+ canvas, parser, course, assignment, target_dir, github_field, all_repos, clone_delay
67
+ )
68
+
69
+
70
+ def _setup_repo_for_assignment(canvas, parser, course, assignment, target_dir, github_field, all_repos, clone_delay):
71
+ """Set up GitHub repos for a single assignment."""
39
72
  submission_dir = os.path.join(target_dir, despace(course.name), despace(assignment.name))
40
73
  os.makedirs(submission_dir, exist_ok=True)
41
74
 
@@ -0,0 +1,143 @@
1
+ #! /usr/bin/python3
2
+ """Install a codeval assignment file and its referenced zip files to a destination."""
3
+
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+
9
+ import click
10
+
11
+
12
+ def parse_z_tags(codeval_path: str) -> list[str]:
13
+ """Parse a codeval file and return a list of zip files referenced by Z tags.
14
+
15
+ Arguments:
16
+ codeval_path: Path to the codeval file
17
+
18
+ Returns:
19
+ List of zip file paths referenced by Z tags
20
+ """
21
+ zip_files = []
22
+ with open(codeval_path, 'r') as f:
23
+ for line in f:
24
+ line = line.strip()
25
+ if line.startswith('Z '):
26
+ zip_file = line[2:].strip()
27
+ if zip_file:
28
+ zip_files.append(zip_file)
29
+ return zip_files
30
+
31
+
32
+ def is_remote_destination(destination: str) -> bool:
33
+ """Check if destination is a remote path (host:path format).
34
+
35
+ Arguments:
36
+ destination: The destination path
37
+
38
+ Returns:
39
+ True if destination is remote (contains : but not a Windows drive letter)
40
+ """
41
+ # Match host:path pattern but not Windows drive letters like C:\
42
+ if ':' not in destination:
43
+ return False
44
+ # Windows drive letter check (e.g., C:\path)
45
+ if re.match(r'^[A-Za-z]:\\', destination):
46
+ return False
47
+ return True
48
+
49
+
50
+ def copy_file(source: str, destination: str, verbose: bool = False) -> None:
51
+ """Copy a file to a local or remote destination.
52
+
53
+ Arguments:
54
+ source: Path to the source file
55
+ destination: Destination path (local or host:path for remote)
56
+ verbose: Print verbose output
57
+
58
+ Raises:
59
+ click.ClickException: If copy fails
60
+ """
61
+ if is_remote_destination(destination):
62
+ # Use scp for remote destinations
63
+ cmd = ['scp', source, destination]
64
+ if verbose:
65
+ click.echo(f"Running: {' '.join(cmd)}")
66
+ result = subprocess.run(cmd, capture_output=True, text=True)
67
+ if result.returncode != 0:
68
+ raise click.ClickException(f"scp failed: {result.stderr}")
69
+ else:
70
+ # Local copy
71
+ if verbose:
72
+ click.echo(f"Copying {source} to {destination}")
73
+ os.makedirs(destination, exist_ok=True)
74
+ shutil.copy2(source, destination)
75
+
76
+
77
+ @click.command()
78
+ @click.argument('codeval_file', type=click.Path(exists=True))
79
+ @click.argument('destination')
80
+ @click.option('--verbose', '-v', is_flag=True, help='Show detailed output')
81
+ def install_assignment(codeval_file: str, destination: str, verbose: bool):
82
+ """Install a codeval file and its referenced zip files to a destination.
83
+
84
+ CODEVAL_FILE is the path to the codeval specification file.
85
+
86
+ DESTINATION is either a local path or a remote path in host:path format.
87
+ If remote, scp will be used to copy files.
88
+
89
+ \b
90
+ Examples:
91
+ # Local install
92
+ assignment-codeval install-assignment myassign.codeval /path/to/dest
93
+
94
+ # Remote install
95
+ assignment-codeval install-assignment myassign.codeval server:/home/user/assignments
96
+ """
97
+ codeval_path = os.path.abspath(codeval_file)
98
+ codeval_dir = os.path.dirname(codeval_path)
99
+ codeval_name = os.path.basename(codeval_path)
100
+
101
+ # Parse Z tags to find referenced zip files
102
+ zip_files = parse_z_tags(codeval_path)
103
+
104
+ # Build list of files to copy
105
+ files_to_copy = [codeval_path]
106
+
107
+ for zip_file in zip_files:
108
+ # Zip files are relative to the codeval file's directory
109
+ zip_path = os.path.join(codeval_dir, zip_file)
110
+ if os.path.exists(zip_path):
111
+ files_to_copy.append(zip_path)
112
+ else:
113
+ click.echo(f"Warning: Referenced zip file not found: {zip_path}", err=True)
114
+
115
+ # Show summary
116
+ click.echo(f"Codeval file: {codeval_name}")
117
+ if zip_files:
118
+ click.echo(f"Referenced zip files: {', '.join(zip_files)}")
119
+ else:
120
+ click.echo("No zip files referenced")
121
+ click.echo(f"Destination: {destination}")
122
+
123
+ if is_remote_destination(destination):
124
+ click.echo("Using scp for remote copy")
125
+
126
+ # Copy files
127
+ for source_path in files_to_copy:
128
+ filename = os.path.basename(source_path)
129
+ if is_remote_destination(destination):
130
+ # For remote, append filename to destination
131
+ dest_path = f"{destination}/{filename}"
132
+ else:
133
+ dest_path = destination
134
+
135
+ try:
136
+ copy_file(source_path, dest_path, verbose)
137
+ click.echo(f"Copied: {filename}")
138
+ except click.ClickException as e:
139
+ raise e
140
+ except Exception as e:
141
+ raise click.ClickException(f"Failed to copy {filename}: {e}")
142
+
143
+ click.echo("Done")
@@ -4,7 +4,7 @@ import shutil
4
4
  import subprocess
5
5
  import time
6
6
  from configparser import ConfigParser
7
- from datetime import datetime, timezone
7
+ from datetime import datetime, timedelta, timezone
8
8
  from functools import cache
9
9
  from tempfile import TemporaryDirectory
10
10
  from zipfile import ZipFile
@@ -12,7 +12,7 @@ from zipfile import ZipFile
12
12
  import click
13
13
  import requests
14
14
 
15
- from assignment_codeval.canvas_utils import connect_to_canvas, get_course, get_assignment
15
+ from assignment_codeval.canvas_utils import connect_to_canvas, get_course, get_courses, get_assignment
16
16
  from assignment_codeval.commons import debug, error, info, warn, despace
17
17
 
18
18
 
@@ -90,9 +90,11 @@ def evaluate_submissions(codeval_dir, submissions_dir):
90
90
  warn(f"no codeval file found for {assignment_name} in {codeval_file}")
91
91
  continue
92
92
 
93
- # get the zipfiles (Z tag) and timeout (CTO tag)
93
+ # First pass: get CTO, CD tags, and collect Z files (don't extract yet)
94
94
  compile_timeout = 20
95
95
  assignment_working_dir = "."
96
+ has_cd_tag = False
97
+ zip_files = []
96
98
  move_to_next_submission = False
97
99
  with open(codeval_file, "r") as fd:
98
100
  for line in fd:
@@ -103,6 +105,7 @@ def evaluate_submissions(codeval_dir, submissions_dir):
103
105
  except Exception:
104
106
  warn(f"could not parse compile timeout from {line}, using default {compile_timeout}")
105
107
  if line.startswith("CD"):
108
+ has_cd_tag = True
106
109
  assignment_working_dir = os.path.normpath(
107
110
  os.path.join(assignment_working_dir, line.split()[1].strip()))
108
111
  if not os.path.isdir(os.path.join(submission_dir, assignment_working_dir)):
@@ -110,16 +113,26 @@ def evaluate_submissions(codeval_dir, submissions_dir):
110
113
  move_to_next_submission = True
111
114
  break
112
115
  if line.startswith("Z"):
113
- zipfile = line.split(None, 1)[1]
114
- # unzip into the submission directory
115
- with ZipFile(os.path.join(codeval_dir, zipfile)) as zf:
116
- for f in zf.infolist():
117
- dest_dir = os.path.join(submission_dir, assignment_working_dir)
118
- zf.extract(f, dest_dir)
119
- if not f.is_dir():
120
- perms = f.external_attr >> 16
121
- if perms:
122
- os.chmod(os.path.join(dest_dir, f.filename), perms)
116
+ zip_files.append(line.split(None, 1)[1])
117
+
118
+ # If no CD tag and this is a GitHub submission (has .git), use assignment name as working dir
119
+ if not has_cd_tag and os.path.exists(os.path.join(submission_dir, assignment_name, ".git")):
120
+ assignment_working_dir = assignment_name
121
+ if not os.path.isdir(os.path.join(submission_dir, assignment_working_dir)):
122
+ out = f"{assignment_working_dir} does not exist or is not a directory\n".encode('utf-8')
123
+ move_to_next_submission = True
124
+
125
+ # Now extract zip files to the correct working directory
126
+ if not move_to_next_submission:
127
+ for zf_name in zip_files:
128
+ with ZipFile(os.path.join(codeval_dir, zf_name)) as zf:
129
+ for f in zf.infolist():
130
+ dest_dir = os.path.join(submission_dir, assignment_working_dir)
131
+ zf.extract(f, dest_dir)
132
+ if not f.is_dir():
133
+ perms = f.external_attr >> 16
134
+ if perms:
135
+ os.chmod(os.path.join(dest_dir, f.filename), perms)
123
136
 
124
137
  if not move_to_next_submission:
125
138
  command = raw_command.replace("EVALUATE", "cd /submissions; assignment-codeval run-evaluation codeval.txt")
@@ -151,14 +164,17 @@ def evaluate_submissions(codeval_dir, submissions_dir):
151
164
  info("finished executing docker")
152
165
 
153
166
  info("writing results")
154
- with open(f"{dirpath}/comments.txt", "wb") as fd:
167
+ with open(f"{dirpath}/comments.txt", "ab") as fd:
155
168
  fd.write(out)
156
169
  info("continuing")
157
170
 
158
171
 
159
172
  @click.command()
160
- @click.argument("course_name", metavar="COURSE")
161
- @click.argument("assignment_name", metavar="ASSIGNMENT")
173
+ @click.argument("course_name", metavar="COURSE", required=False)
174
+ @click.argument("assignment_name", metavar="ASSIGNMENT", required=False)
175
+ @click.option("--active", is_flag=True, help="download from all active assignments in all active courses")
176
+ @click.option("--until-window", default=24, show_default=True,
177
+ help="hours after the until date to still consider an assignment active")
162
178
  @click.option("--target-dir", help="directory to download submissions to", default='./submissions', show_default=True)
163
179
  @click.option("--include-commented", is_flag=True,
164
180
  help="even download submissionsthat already have codeval comments since last submission")
@@ -168,17 +184,78 @@ def evaluate_submissions(codeval_dir, submissions_dir):
168
184
  @click.option("--codeval-prefix", help="prefix for codeval comments", default="codeval: ", show_default=True)
169
185
  @click.option("--include-empty", is_flag=True, help="include empty submissions")
170
186
  @click.option("--for-name", help="only download submissions for this student name")
171
- def download_submissions(course_name, assignment_name, target_dir, include_commented, codeval_prefix, include_empty,
172
- uncommented_for, for_name):
187
+ def download_submissions(course_name, assignment_name, active, until_window, target_dir, include_commented,
188
+ codeval_prefix, include_empty, uncommented_for, for_name):
173
189
  """
174
190
  Download submissions for a given assignment in a course from Canvas.
175
191
 
176
- the COURSE and ASSIGNMENT arguments can be partial names.
192
+ The COURSE and ASSIGNMENT arguments can be partial names.
193
+
194
+ Use --active to download from all active assignments in all active courses
195
+ (COURSE and ASSIGNMENT are not required when --active is used).
177
196
  """
197
+ if not active and (not course_name or not assignment_name):
198
+ raise click.UsageError("COURSE and ASSIGNMENT are required unless --active is specified")
199
+
178
200
  (canvas, user) = connect_to_canvas()
179
201
 
202
+ if active:
203
+ # Load codeval directory from config
204
+ parser = ConfigParser()
205
+ config_file = click.get_app_dir("codeval.ini")
206
+ parser.read(config_file)
207
+ if 'CODEVAL' not in parser or 'directory' not in parser['CODEVAL']:
208
+ raise click.UsageError(f"[CODEVAL] section with directory= is required in {config_file}")
209
+ codeval_dir = parser['CODEVAL']['directory']
210
+
211
+ courses = get_courses(canvas, course_name or "", is_active=True)
212
+ if not courses:
213
+ error("no active courses found")
214
+ return
215
+
216
+ now = datetime.now(timezone.utc)
217
+ for course in courses:
218
+ for assignment in course.get_assignments():
219
+ if assignment_name and assignment_name.lower() not in despace(assignment.name).lower():
220
+ continue
221
+ # Check if assignment is active: availability date passed and until date + window not passed
222
+ unlock_at = getattr(assignment, 'unlock_at_date', None)
223
+ lock_at = getattr(assignment, 'lock_at_date', None)
224
+ if unlock_at and unlock_at > now:
225
+ continue # not yet available
226
+ if lock_at:
227
+ window = timedelta(hours=until_window)
228
+ if lock_at + window < now:
229
+ continue # past the until window
230
+
231
+ # Filter by submission type: only text_entry (GitHub) or file upload assignments
232
+ submission_types = getattr(assignment, 'submission_types', [])
233
+ if "online_text_entry" not in submission_types and "online_upload" not in submission_types:
234
+ continue
235
+
236
+ # Only download if a corresponding codeval file exists
237
+ codeval_file = os.path.join(codeval_dir, f"{despace(assignment.name)}.codeval")
238
+ if not os.path.exists(codeval_file):
239
+ continue
240
+
241
+ info(f"downloading submissions for {course.name}: {assignment.name}")
242
+ _download_assignment_submissions(
243
+ course, assignment, target_dir, include_commented, codeval_prefix,
244
+ include_empty, uncommented_for, for_name
245
+ )
246
+ return
247
+
180
248
  course = get_course(canvas, course_name)
181
249
  assignment = get_assignment(course, assignment_name)
250
+ _download_assignment_submissions(
251
+ course, assignment, target_dir, include_commented, codeval_prefix,
252
+ include_empty, uncommented_for, for_name
253
+ )
254
+
255
+
256
+ def _download_assignment_submissions(course, assignment, target_dir, include_commented, codeval_prefix,
257
+ include_empty, uncommented_for, for_name):
258
+ """Download submissions for a single assignment."""
182
259
  submission_dir = os.path.join(target_dir, despace(course.name), despace(assignment.name))
183
260
  os.makedirs(submission_dir, exist_ok=True)
184
261
 
@@ -221,6 +298,18 @@ attempt={submission.attempt}
221
298
  late={submission.late}
222
299
  date={submission.submitted_at}
223
300
  last_comment={last_comment_date}""", file=fd)
301
+
302
+ # Save the last comment (any comment, not just codeval ones) to last-comment.txt
303
+ if submission.submission_comments:
304
+ all_comments = sorted(submission.submission_comments, key=lambda c: c['created_at'])
305
+ last_comment = all_comments[-1]
306
+ last_comment_path = os.path.join(student_submission_dir, "last-comment.txt")
307
+ with open(last_comment_path, "w") as fd:
308
+ print(f"date={last_comment['created_at']}", file=fd)
309
+ print(f"author={last_comment.get('author_name', 'unknown')}", file=fd)
310
+ print("", file=fd)
311
+ print(last_comment.get('comment', ''), file=fd)
312
+
224
313
  body = submission.body
225
314
  if body:
226
315
  filepath = os.path.join(student_submission_dir, "content.txt")
@@ -246,3 +335,46 @@ def get_submissions_by_id(assignment):
246
335
  student_id = str(submission.user_id)
247
336
  submissions_by_id[student_id] = submission
248
337
  return submissions_by_id
338
+
339
+
340
+ @click.command()
341
+ @click.argument("course_name", metavar="COURSE", required=False)
342
+ def list_codeval_assignments(course_name):
343
+ """
344
+ List all assignments that have corresponding codeval files.
345
+
346
+ Optionally filter by COURSE (partial name match).
347
+ Reports errors for codeval files that don't match any assignment.
348
+ """
349
+ # Load codeval directory from config
350
+ parser = ConfigParser()
351
+ config_file = click.get_app_dir("codeval.ini")
352
+ parser.read(config_file)
353
+ if 'CODEVAL' not in parser or 'directory' not in parser['CODEVAL']:
354
+ raise click.UsageError(f"[CODEVAL] section with directory= is required in {config_file}")
355
+ codeval_dir = parser['CODEVAL']['directory']
356
+
357
+ if not os.path.isdir(codeval_dir):
358
+ raise click.UsageError(f"CODEVAL directory does not exist: {codeval_dir}")
359
+
360
+ # Get all codeval files in the directory
361
+ codeval_files = {f[:-8] for f in os.listdir(codeval_dir) if f.endswith('.codeval')}
362
+ matched_codeval_files = set()
363
+
364
+ (canvas, user) = connect_to_canvas()
365
+ courses = get_courses(canvas, course_name or "", is_active=True)
366
+ if not courses:
367
+ error("no active courses found")
368
+ return
369
+
370
+ for course in courses:
371
+ for assignment in course.get_assignments():
372
+ assignment_key = despace(assignment.name)
373
+ if assignment_key in codeval_files:
374
+ matched_codeval_files.add(assignment_key)
375
+ info(f"{course.name}: {assignment.name}")
376
+
377
+ # Report codeval files that don't match any assignment
378
+ unmatched = codeval_files - matched_codeval_files
379
+ for codeval_name in sorted(unmatched):
380
+ error(f"codeval file has no matching assignment: {codeval_name}.codeval")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assignment-codeval
3
- Version: 0.0.14
3
+ Version: 0.0.16
4
4
  Summary: CodEval for evaluating programming assignments
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -10,6 +10,7 @@ src/assignment_codeval/create_assignment.py
10
10
  src/assignment_codeval/evaluate.py
11
11
  src/assignment_codeval/file_utils.py
12
12
  src/assignment_codeval/github_connect.py
13
+ src/assignment_codeval/install_assignment.py
13
14
  src/assignment_codeval/submissions.py
14
15
  src/assignment_codeval.egg-info/PKG-INFO
15
16
  src/assignment_codeval.egg-info/SOURCES.txt
@@ -18,4 +19,6 @@ src/assignment_codeval.egg-info/entry_points.txt
18
19
  src/assignment_codeval.egg-info/requires.txt
19
20
  src/assignment_codeval.egg-info/top_level.txt
20
21
  tests/test_codeval.py
21
- tests/test_create_assignment.py
22
+ tests/test_create_assignment.py
23
+ tests/test_evaluate_submissions.py
24
+ tests/test_install_assignment.py
@@ -0,0 +1,158 @@
1
+ """Tests for evaluate_submissions command."""
2
+
3
+ import os
4
+ import tempfile
5
+ import zipfile
6
+
7
+ import pytest
8
+
9
+
10
+ class TestGitHubSubmissionWorkingDir:
11
+ """Tests for GitHub submission working directory detection."""
12
+
13
+ def test_zip_extracted_to_assignment_dir_for_github(self):
14
+ """Verify zip files are extracted to assignment directory for GitHub submissions.
15
+
16
+ When a GitHub submission exists (has .git directory) and no CD tag is specified,
17
+ zip files should be extracted to the assignment_name subdirectory, not the root.
18
+ """
19
+ with tempfile.TemporaryDirectory() as tmpdir:
20
+ # Setup directory structure:
21
+ # tmpdir/
22
+ # codeval/
23
+ # TestAssignment.codeval
24
+ # support.zip
25
+ # submissions/
26
+ # Course/
27
+ # TestAssignment/
28
+ # 12345/
29
+ # submission/
30
+ # TestAssignment/
31
+ # .git/
32
+ # main.py
33
+
34
+ codeval_dir = os.path.join(tmpdir, "codeval")
35
+ submissions_dir = os.path.join(tmpdir, "submissions")
36
+ os.makedirs(codeval_dir)
37
+
38
+ assignment_name = "TestAssignment"
39
+ student_dir = os.path.join(submissions_dir, "Course", assignment_name, "12345")
40
+ submission_dir = os.path.join(student_dir, "submission")
41
+ git_repo_dir = os.path.join(submission_dir, assignment_name)
42
+ os.makedirs(os.path.join(git_repo_dir, ".git"))
43
+
44
+ # Create a dummy file in the git repo
45
+ with open(os.path.join(git_repo_dir, "main.py"), "w") as f:
46
+ f.write("print('hello')\n")
47
+
48
+ # Create a zip file with a helper file
49
+ zip_path = os.path.join(codeval_dir, "support.zip")
50
+ with zipfile.ZipFile(zip_path, 'w') as zf:
51
+ zf.writestr("helper.txt", "helper content")
52
+
53
+ # Create codeval file with Z tag but NO CD tag
54
+ codeval_path = os.path.join(codeval_dir, f"{assignment_name}.codeval")
55
+ with open(codeval_path, "w") as f:
56
+ f.write("Z support.zip\n")
57
+ f.write("T echo test\n")
58
+ f.write("O test\n")
59
+
60
+ # Parse the codeval file and check where zip would be extracted
61
+ # This simulates what evaluate_submissions does
62
+ from assignment_codeval.submissions import evaluate_submissions
63
+ from zipfile import ZipFile
64
+
65
+ # Manually trace through the logic to verify the bug
66
+ assignment_working_dir = "."
67
+ has_cd_tag = False
68
+ zip_files = []
69
+
70
+ with open(codeval_path, "r") as fd:
71
+ for line in fd:
72
+ line = line.strip()
73
+ if line.startswith("CD"):
74
+ has_cd_tag = True
75
+ if line.startswith("Z"):
76
+ zip_files.append(line.split(None, 1)[1])
77
+
78
+ # Check for GitHub submission BEFORE extracting
79
+ is_github = os.path.exists(os.path.join(submission_dir, assignment_name, ".git"))
80
+ assert is_github, "Test setup: .git directory should exist"
81
+
82
+ if not has_cd_tag and is_github:
83
+ assignment_working_dir = assignment_name
84
+
85
+ # Now extract zip files to the correct location
86
+ for zf_name in zip_files:
87
+ with ZipFile(os.path.join(codeval_dir, zf_name)) as zf:
88
+ dest_dir = os.path.join(submission_dir, assignment_working_dir)
89
+ zf.extractall(dest_dir)
90
+
91
+ # Verify helper.txt was extracted to the assignment directory, not root
92
+ correct_path = os.path.join(submission_dir, assignment_name, "helper.txt")
93
+ wrong_path = os.path.join(submission_dir, "helper.txt")
94
+
95
+ assert os.path.exists(correct_path), \
96
+ f"helper.txt should be at {correct_path}"
97
+ assert not os.path.exists(wrong_path), \
98
+ f"helper.txt should NOT be at {wrong_path} (root of submission_dir)"
99
+
100
+ def test_zip_extracted_to_root_when_cd_tag_present(self):
101
+ """Verify zip files respect CD tag even for GitHub submissions."""
102
+ with tempfile.TemporaryDirectory() as tmpdir:
103
+ codeval_dir = os.path.join(tmpdir, "codeval")
104
+ submissions_dir = os.path.join(tmpdir, "submissions")
105
+ os.makedirs(codeval_dir)
106
+
107
+ assignment_name = "TestAssignment"
108
+ student_dir = os.path.join(submissions_dir, "Course", assignment_name, "12345")
109
+ submission_dir = os.path.join(student_dir, "submission")
110
+
111
+ # Create custom directory specified by CD tag
112
+ custom_dir = os.path.join(submission_dir, "custom_dir")
113
+ os.makedirs(os.path.join(custom_dir, ".git"))
114
+
115
+ # Create codeval file WITH CD tag
116
+ codeval_path = os.path.join(codeval_dir, f"{assignment_name}.codeval")
117
+ with open(codeval_path, "w") as f:
118
+ f.write("CD custom_dir\n")
119
+ f.write("Z support.zip\n")
120
+ f.write("T echo test\n")
121
+ f.write("O test\n")
122
+
123
+ # Create zip file
124
+ zip_path = os.path.join(codeval_dir, "support.zip")
125
+ with zipfile.ZipFile(zip_path, 'w') as zf:
126
+ zf.writestr("helper.txt", "helper content")
127
+
128
+ # Trace through logic
129
+ from zipfile import ZipFile
130
+
131
+ assignment_working_dir = "."
132
+ has_cd_tag = False
133
+ zip_files = []
134
+
135
+ with open(codeval_path, "r") as fd:
136
+ for line in fd:
137
+ line = line.strip()
138
+ if line.startswith("CD"):
139
+ has_cd_tag = True
140
+ assignment_working_dir = os.path.normpath(
141
+ os.path.join(assignment_working_dir, line.split()[1].strip()))
142
+ if line.startswith("Z"):
143
+ zip_files.append(line.split(None, 1)[1])
144
+
145
+ # CD tag should take precedence
146
+ assert has_cd_tag
147
+ assert assignment_working_dir == "custom_dir"
148
+
149
+ # Extract zip files
150
+ for zf_name in zip_files:
151
+ with ZipFile(os.path.join(codeval_dir, zf_name)) as zf:
152
+ dest_dir = os.path.join(submission_dir, assignment_working_dir)
153
+ zf.extractall(dest_dir)
154
+
155
+ # Verify helper.txt was extracted to custom_dir
156
+ correct_path = os.path.join(submission_dir, "custom_dir", "helper.txt")
157
+ assert os.path.exists(correct_path), \
158
+ f"helper.txt should be at {correct_path}"
@@ -0,0 +1,183 @@
1
+ """Tests for install_assignment command."""
2
+
3
+ import os
4
+ import tempfile
5
+ import zipfile
6
+ from unittest.mock import patch, MagicMock
7
+
8
+ import pytest
9
+ from click.testing import CliRunner
10
+
11
+ from assignment_codeval.install_assignment import (
12
+ install_assignment,
13
+ is_remote_destination,
14
+ parse_z_tags,
15
+ )
16
+
17
+
18
+ class TestIsRemoteDestination:
19
+ """Tests for is_remote_destination function."""
20
+
21
+ def test_local_path(self):
22
+ assert is_remote_destination('/home/user/path') is False
23
+
24
+ def test_relative_path(self):
25
+ assert is_remote_destination('relative/path') is False
26
+
27
+ def test_remote_path(self):
28
+ assert is_remote_destination('server:/home/user/path') is True
29
+
30
+ def test_remote_path_with_user(self):
31
+ assert is_remote_destination('user@server:/path') is True
32
+
33
+ def test_windows_drive_letter(self):
34
+ assert is_remote_destination('C:\\Users\\path') is False
35
+
36
+ def test_windows_drive_lowercase(self):
37
+ assert is_remote_destination('c:\\Users\\path') is False
38
+
39
+
40
+ class TestParseZTags:
41
+ """Tests for parse_z_tags function."""
42
+
43
+ def test_no_z_tags(self):
44
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.codeval', delete=False) as f:
45
+ f.write('C gcc -o test test.c\n')
46
+ f.write('T ./test\n')
47
+ f.name
48
+ try:
49
+ result = parse_z_tags(f.name)
50
+ assert result == []
51
+ finally:
52
+ os.unlink(f.name)
53
+
54
+ def test_single_z_tag(self):
55
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.codeval', delete=False) as f:
56
+ f.write('Z support.zip\n')
57
+ f.write('C gcc -o test test.c\n')
58
+ try:
59
+ result = parse_z_tags(f.name)
60
+ assert result == ['support.zip']
61
+ finally:
62
+ os.unlink(f.name)
63
+
64
+ def test_multiple_z_tags(self):
65
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.codeval', delete=False) as f:
66
+ f.write('Z first.zip\n')
67
+ f.write('Z second.zip\n')
68
+ f.write('C gcc -o test test.c\n')
69
+ try:
70
+ result = parse_z_tags(f.name)
71
+ assert result == ['first.zip', 'second.zip']
72
+ finally:
73
+ os.unlink(f.name)
74
+
75
+ def test_z_tag_with_whitespace(self):
76
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.codeval', delete=False) as f:
77
+ f.write('Z support.zip \n')
78
+ try:
79
+ result = parse_z_tags(f.name)
80
+ assert result == ['support.zip']
81
+ finally:
82
+ os.unlink(f.name)
83
+
84
+
85
+ class TestInstallAssignmentCommand:
86
+ """Tests for the install_assignment CLI command."""
87
+
88
+ def test_local_install_codeval_only(self):
89
+ runner = CliRunner()
90
+ with tempfile.TemporaryDirectory() as tmpdir:
91
+ # Create a codeval file
92
+ codeval_path = os.path.join(tmpdir, 'test.codeval')
93
+ with open(codeval_path, 'w') as f:
94
+ f.write('C gcc -o test test.c\n')
95
+ f.write('T ./test\n')
96
+
97
+ # Create destination directory
98
+ dest_dir = os.path.join(tmpdir, 'dest')
99
+ os.makedirs(dest_dir)
100
+
101
+ result = runner.invoke(install_assignment, [codeval_path, dest_dir])
102
+ assert result.exit_code == 0
103
+ assert 'No zip files referenced' in result.output
104
+ assert 'Done' in result.output
105
+ assert os.path.exists(os.path.join(dest_dir, 'test.codeval'))
106
+
107
+ def test_local_install_with_zip(self):
108
+ runner = CliRunner()
109
+ with tempfile.TemporaryDirectory() as tmpdir:
110
+ # Create a zip file
111
+ zip_path = os.path.join(tmpdir, 'support.zip')
112
+ with zipfile.ZipFile(zip_path, 'w') as zf:
113
+ zf.writestr('helper.txt', 'test content')
114
+
115
+ # Create a codeval file referencing the zip
116
+ codeval_path = os.path.join(tmpdir, 'test.codeval')
117
+ with open(codeval_path, 'w') as f:
118
+ f.write('Z support.zip\n')
119
+ f.write('C gcc -o test test.c\n')
120
+
121
+ # Create destination directory
122
+ dest_dir = os.path.join(tmpdir, 'dest')
123
+ os.makedirs(dest_dir)
124
+
125
+ result = runner.invoke(install_assignment, [codeval_path, dest_dir])
126
+ assert result.exit_code == 0
127
+ assert 'support.zip' in result.output
128
+ assert 'Done' in result.output
129
+ assert os.path.exists(os.path.join(dest_dir, 'test.codeval'))
130
+ assert os.path.exists(os.path.join(dest_dir, 'support.zip'))
131
+
132
+ def test_missing_zip_warning(self):
133
+ runner = CliRunner()
134
+ with tempfile.TemporaryDirectory() as tmpdir:
135
+ # Create a codeval file referencing a non-existent zip
136
+ codeval_path = os.path.join(tmpdir, 'test.codeval')
137
+ with open(codeval_path, 'w') as f:
138
+ f.write('Z missing.zip\n')
139
+ f.write('C gcc -o test test.c\n')
140
+
141
+ # Create destination directory
142
+ dest_dir = os.path.join(tmpdir, 'dest')
143
+ os.makedirs(dest_dir)
144
+
145
+ result = runner.invoke(install_assignment, [codeval_path, dest_dir])
146
+ assert result.exit_code == 0
147
+ assert 'Warning: Referenced zip file not found' in result.output
148
+
149
+ def test_verbose_output(self):
150
+ runner = CliRunner()
151
+ with tempfile.TemporaryDirectory() as tmpdir:
152
+ codeval_path = os.path.join(tmpdir, 'test.codeval')
153
+ with open(codeval_path, 'w') as f:
154
+ f.write('C gcc -o test test.c\n')
155
+
156
+ dest_dir = os.path.join(tmpdir, 'dest')
157
+ os.makedirs(dest_dir)
158
+
159
+ result = runner.invoke(install_assignment, [codeval_path, dest_dir, '--verbose'])
160
+ assert result.exit_code == 0
161
+ assert 'Copying' in result.output
162
+
163
+ @patch('assignment_codeval.install_assignment.subprocess.run')
164
+ def test_remote_destination_detected(self, mock_run):
165
+ # Mock successful scp
166
+ mock_run.return_value = MagicMock(returncode=0)
167
+
168
+ runner = CliRunner()
169
+ with tempfile.TemporaryDirectory() as tmpdir:
170
+ codeval_path = os.path.join(tmpdir, 'test.codeval')
171
+ with open(codeval_path, 'w') as f:
172
+ f.write('C gcc -o test test.c\n')
173
+
174
+ result = runner.invoke(install_assignment, [codeval_path, 'server:/path'])
175
+ assert result.exit_code == 0
176
+ assert 'Using scp for remote copy' in result.output
177
+ # Verify scp was called
178
+ mock_run.assert_called()
179
+
180
+ def test_nonexistent_codeval_file(self):
181
+ runner = CliRunner()
182
+ result = runner.invoke(install_assignment, ['/nonexistent/file.codeval', '/dest'])
183
+ assert result.exit_code != 0