assignment-codeval 0.0.15__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.15 → assignment_codeval-0.0.16}/PKG-INFO +1 -1
  2. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/pyproject.toml +1 -1
  3. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/cli.py +2 -0
  4. assignment_codeval-0.0.16/src/assignment_codeval/install_assignment.py +143 -0
  5. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/submissions.py +24 -11
  6. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/PKG-INFO +1 -1
  7. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/SOURCES.txt +4 -1
  8. assignment_codeval-0.0.16/tests/test_evaluate_submissions.py +158 -0
  9. assignment_codeval-0.0.16/tests/test_install_assignment.py +183 -0
  10. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/README.md +0 -0
  11. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/setup.cfg +0 -0
  12. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/__init__.py +0 -0
  13. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/ai_benchmark.py +0 -0
  14. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/canvas_utils.py +0 -0
  15. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/commons.py +0 -0
  16. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/convertMD2Html.py +0 -0
  17. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/create_assignment.py +0 -0
  18. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/evaluate.py +0 -0
  19. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/file_utils.py +0 -0
  20. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval/github_connect.py +0 -0
  21. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/dependency_links.txt +0 -0
  22. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/entry_points.txt +0 -0
  23. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/requires.txt +0 -0
  24. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/src/assignment_codeval.egg-info/top_level.txt +0 -0
  25. {assignment_codeval-0.0.15 → assignment_codeval-0.0.16}/tests/test_codeval.py +0 -0
  26. {assignment_codeval-0.0.15 → 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.15
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.15"
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,6 +4,7 @@ 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.install_assignment import install_assignment
7
8
  from assignment_codeval.submissions import download_submissions, upload_submission_comments, evaluate_submissions, list_codeval_assignments
8
9
 
9
10
 
@@ -18,6 +19,7 @@ cli.add_command(github_setup_repo)
18
19
  cli.add_command(evaluate_submissions)
19
20
  cli.add_command(create_assignment)
20
21
  cli.add_command(list_codeval_assignments)
22
+ cli.add_command(install_assignment)
21
23
  cli.add_command(get_benchmark_command())
22
24
 
23
25
  if __name__ == "__main__":
@@ -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")
@@ -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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assignment-codeval
3
- Version: 0.0.15
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