ygrader 1.2.3__tar.gz → 2.0.0__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.
- {ygrader-1.2.3/ygrader.egg-info → ygrader-2.0.0}/PKG-INFO +1 -1
- {ygrader-1.2.3 → ygrader-2.0.0}/setup.py +1 -1
- {ygrader-1.2.3 → ygrader-2.0.0}/test/test_interactive.py +0 -2
- ygrader-2.0.0/test/test_unittest.py +139 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/__init__.py +5 -2
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/deductions.py +51 -2
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/feedback.py +16 -21
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/grader.py +61 -70
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/grades_csv.py +5 -5
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/grading_item.py +80 -127
- ygrader-2.0.0/ygrader/grading_item_config.py +99 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/score_input.py +3 -16
- {ygrader-1.2.3 → ygrader-2.0.0/ygrader.egg-info}/PKG-INFO +1 -1
- ygrader-1.2.3/test/test_unittest.py +0 -105
- ygrader-1.2.3/ygrader/grading_item_config.py +0 -129
- {ygrader-1.2.3 → ygrader-2.0.0}/LICENSE +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/setup.cfg +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/student_repos.py +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/upstream_merger.py +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader/utils.py +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader.egg-info/SOURCES.txt +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-1.2.3 → ygrader-2.0.0}/ygrader.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ setup(
|
|
|
4
4
|
name="ygrader",
|
|
5
5
|
packages=["ygrader"],
|
|
6
6
|
package_data={"ygrader": ["*.ahk"]},
|
|
7
|
-
version="
|
|
7
|
+
version="2.0.0",
|
|
8
8
|
description="Grading scripts used in BYU's Electrical and Computer Engineering Department",
|
|
9
9
|
author="Jeff Goeders",
|
|
10
10
|
author_email="jeff.goeders@gmail.com",
|
|
@@ -35,12 +35,10 @@ def test_me():
|
|
|
35
35
|
"lab1",
|
|
36
36
|
run_on_milestone,
|
|
37
37
|
10,
|
|
38
|
-
help_msg="This is a long string\nWith lots of advice\nFor TAs",
|
|
39
38
|
)
|
|
40
39
|
grader.add_item_to_grade(
|
|
41
40
|
"lab1m2",
|
|
42
41
|
run_on_milestone,
|
|
43
|
-
help_msg="msg2",
|
|
44
42
|
)
|
|
45
43
|
grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions.zip")
|
|
46
44
|
grader.set_other_options(prep_fcn=run_on_lab)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import filecmp
|
|
7
|
+
import doctest
|
|
8
|
+
|
|
9
|
+
ROOT_PATH = pathlib.Path(__file__).resolve().parent.parent
|
|
10
|
+
sys.path.append(str(ROOT_PATH))
|
|
11
|
+
|
|
12
|
+
import ygrader.student_repos
|
|
13
|
+
from ygrader import Grader, CodeSource
|
|
14
|
+
|
|
15
|
+
TEST_PATH = ROOT_PATH / "test"
|
|
16
|
+
TEST_RESOURCES_PATH = TEST_PATH / "resources"
|
|
17
|
+
TEST_OUTPUT_PATH = TEST_PATH / "grades"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_tests(loader, tests, ignore):
|
|
21
|
+
tests.addTests(doctest.DocTestSuite(ygrader.student_repos))
|
|
22
|
+
return tests
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestGithub(unittest.TestCase):
|
|
26
|
+
def test_me(self):
|
|
27
|
+
class_list_csv_path = TEST_RESOURCES_PATH / "class_list_1.csv"
|
|
28
|
+
deductions1_golden_path = TEST_RESOURCES_PATH / "deductions1_golden.yaml"
|
|
29
|
+
deductions2_golden_path = TEST_RESOURCES_PATH / "deductions2_golden.yaml"
|
|
30
|
+
deductions1_yaml_path = TEST_OUTPUT_PATH / "deductions1.yaml"
|
|
31
|
+
deductions2_yaml_path = TEST_OUTPUT_PATH / "deductions2.yaml"
|
|
32
|
+
|
|
33
|
+
grader = Grader(
|
|
34
|
+
lab_name="github_test",
|
|
35
|
+
class_list_csv_path=class_list_csv_path,
|
|
36
|
+
work_path=TEST_PATH / "temp_github",
|
|
37
|
+
)
|
|
38
|
+
grader.add_item_to_grade(
|
|
39
|
+
"lab1",
|
|
40
|
+
self.runner,
|
|
41
|
+
max_points=10,
|
|
42
|
+
deductions_yaml_path=deductions1_yaml_path,
|
|
43
|
+
)
|
|
44
|
+
grader.add_item_to_grade(
|
|
45
|
+
"lab1m2",
|
|
46
|
+
self.runner,
|
|
47
|
+
max_points=20,
|
|
48
|
+
deductions_yaml_path=deductions2_yaml_path,
|
|
49
|
+
)
|
|
50
|
+
grader.set_submission_system_github(
|
|
51
|
+
"main", TEST_RESOURCES_PATH / "github.csv", use_https=True
|
|
52
|
+
)
|
|
53
|
+
grader.run()
|
|
54
|
+
|
|
55
|
+
self.assertTrue(filecmp.cmp(deductions1_yaml_path, deductions1_golden_path))
|
|
56
|
+
self.assertTrue(filecmp.cmp(deductions2_yaml_path, deductions2_golden_path))
|
|
57
|
+
|
|
58
|
+
def runner(self, **kw):
|
|
59
|
+
if kw["item_name"] == "lab1m2":
|
|
60
|
+
return [("New feedback", 2)]
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestLearningSuite(unittest.TestCase):
|
|
65
|
+
def test_me(self):
|
|
66
|
+
class_list_csv_path = TEST_RESOURCES_PATH / "class_list_2.csv"
|
|
67
|
+
deductions_path = TEST_OUTPUT_PATH / "learningsuite_deductions.yaml"
|
|
68
|
+
deductions_golden_path = (
|
|
69
|
+
TEST_RESOURCES_PATH / "learningsuite_deductions_golden.yaml"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
grader = Grader(
|
|
73
|
+
lab_name="learningsuite_test",
|
|
74
|
+
class_list_csv_path=class_list_csv_path,
|
|
75
|
+
work_path=TEST_PATH / "temp_learningsuite",
|
|
76
|
+
)
|
|
77
|
+
grader.add_item_to_grade(
|
|
78
|
+
item_name="lab1",
|
|
79
|
+
grading_fcn=self.runner,
|
|
80
|
+
deductions_yaml_path=deductions_path,
|
|
81
|
+
max_points=10,
|
|
82
|
+
)
|
|
83
|
+
grader.set_submission_system_learning_suite(
|
|
84
|
+
TEST_RESOURCES_PATH / "submissions.zip"
|
|
85
|
+
)
|
|
86
|
+
grader.run()
|
|
87
|
+
|
|
88
|
+
self.assertTrue(filecmp.cmp(deductions_path, deductions_golden_path))
|
|
89
|
+
|
|
90
|
+
def runner(self, student_code_path, **kw):
|
|
91
|
+
self.assertIn("section", kw)
|
|
92
|
+
self.assertIn("homework_id", kw)
|
|
93
|
+
if (student_code_path / "file_1.txt").is_file() and (
|
|
94
|
+
student_code_path / "file_2.txt"
|
|
95
|
+
).is_file():
|
|
96
|
+
return []
|
|
97
|
+
else:
|
|
98
|
+
return [("Missing files", 10)]
|
|
99
|
+
|
|
100
|
+
def test_groups(self):
|
|
101
|
+
class_list_csv_path = TEST_RESOURCES_PATH / "class_list_3.csv"
|
|
102
|
+
deductions1_path = TEST_OUTPUT_PATH / "groups_l1.yaml"
|
|
103
|
+
deductions2_path = TEST_OUTPUT_PATH / "groups_l2.yaml"
|
|
104
|
+
deductions3_path = TEST_OUTPUT_PATH / "groups_l3.yaml"
|
|
105
|
+
deductions1_golden_path = TEST_RESOURCES_PATH / "groups_l1_golden.yaml"
|
|
106
|
+
deductions2_golden_path = TEST_RESOURCES_PATH / "groups_l2_golden.yaml"
|
|
107
|
+
deductions3_golden_path = TEST_RESOURCES_PATH / "groups_l3_golden.yaml"
|
|
108
|
+
|
|
109
|
+
grader = Grader(
|
|
110
|
+
"groups_test",
|
|
111
|
+
class_list_csv_path=class_list_csv_path,
|
|
112
|
+
work_path=TEST_PATH / "temp_groups",
|
|
113
|
+
)
|
|
114
|
+
grader.add_item_to_grade(
|
|
115
|
+
item_name="l1",
|
|
116
|
+
grading_fcn=self.group_grader_1,
|
|
117
|
+
deductions_yaml_path=deductions1_path,
|
|
118
|
+
max_points=10,
|
|
119
|
+
)
|
|
120
|
+
grader.add_item_to_grade(
|
|
121
|
+
item_name="l2",
|
|
122
|
+
grading_fcn=self.group_grader_2,
|
|
123
|
+
deductions_yaml_path=deductions2_path,
|
|
124
|
+
max_points=10,
|
|
125
|
+
)
|
|
126
|
+
grader.set_submission_system_learning_suite(
|
|
127
|
+
TEST_RESOURCES_PATH / "submissions2.zip"
|
|
128
|
+
)
|
|
129
|
+
grader.set_learning_suite_groups(TEST_RESOURCES_PATH / "groups3.csv")
|
|
130
|
+
grader.run()
|
|
131
|
+
|
|
132
|
+
self.assertTrue(filecmp.cmp(deductions1_path, deductions1_golden_path))
|
|
133
|
+
self.assertTrue(filecmp.cmp(deductions2_path, deductions2_golden_path))
|
|
134
|
+
|
|
135
|
+
def group_grader_1(self, **kw):
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
def group_grader_2(self, **kw):
|
|
139
|
+
return [("Did not follow instructions", 5)]
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""Default imports for package"""
|
|
2
2
|
|
|
3
|
-
from .grader import Grader, CodeSource
|
|
3
|
+
from .grader import Grader, CodeSource
|
|
4
4
|
from .upstream_merger import UpstreamMerger
|
|
5
5
|
from .utils import CallbackFailed
|
|
6
|
-
from .grading_item_config import
|
|
6
|
+
from .grading_item_config import (
|
|
7
|
+
LearningSuiteColumn,
|
|
8
|
+
LearningSuiteColumnParseError,
|
|
9
|
+
)
|
|
7
10
|
from .feedback import generate_feedback_zip
|
|
@@ -138,9 +138,11 @@ class StudentDeductions:
|
|
|
138
138
|
all_student_keys = set(self.deductions_by_students.keys()) | set(
|
|
139
139
|
self.days_late_by_students.keys()
|
|
140
140
|
)
|
|
141
|
-
|
|
141
|
+
# Sort student keys for consistent output ordering
|
|
142
|
+
sorted_student_keys = sorted(all_student_keys)
|
|
143
|
+
if sorted_student_keys:
|
|
142
144
|
student_deduction_list = []
|
|
143
|
-
for student_key in
|
|
145
|
+
for student_key in sorted_student_keys:
|
|
144
146
|
deduction_items = self.deductions_by_students.get(student_key, [])
|
|
145
147
|
# Find the deduction IDs for these deduction items
|
|
146
148
|
deduction_ids = []
|
|
@@ -164,6 +166,9 @@ class StudentDeductions:
|
|
|
164
166
|
)
|
|
165
167
|
data["student_deductions"] = student_deduction_list
|
|
166
168
|
|
|
169
|
+
# Create parent directory if it doesn't exist
|
|
170
|
+
self.yaml_path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
|
|
167
172
|
# Write to file
|
|
168
173
|
with open(self.yaml_path, "w", encoding="utf-8") as f:
|
|
169
174
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
@@ -191,6 +196,24 @@ class StudentDeductions:
|
|
|
191
196
|
self._save()
|
|
192
197
|
return next_id
|
|
193
198
|
|
|
199
|
+
def find_or_create_deduction_type(self, message: str, points: float = 0.0) -> int:
|
|
200
|
+
"""Find an existing deduction type by message, or create a new one if not found.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
message: The deduction message/description to find or create
|
|
204
|
+
points: Points to deduct (used only if creating a new type)
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The ID of the existing or newly created deduction type
|
|
208
|
+
"""
|
|
209
|
+
# Search for existing deduction type with matching message
|
|
210
|
+
for deduction_id, deduction_type in self.deduction_types.items():
|
|
211
|
+
if deduction_type.message == message:
|
|
212
|
+
return deduction_id
|
|
213
|
+
|
|
214
|
+
# Not found, create a new one
|
|
215
|
+
return self.add_deduction_type(message, points)
|
|
216
|
+
|
|
194
217
|
def create_deduction_type_interactive(self) -> int:
|
|
195
218
|
"""Interactively prompt the user to create a new deduction type.
|
|
196
219
|
|
|
@@ -356,6 +379,32 @@ class StudentDeductions:
|
|
|
356
379
|
del self.days_late_by_students[student_key]
|
|
357
380
|
self._save()
|
|
358
381
|
|
|
382
|
+
def ensure_student_in_file(self, net_ids: tuple):
|
|
383
|
+
"""Ensure a student is in the deductions file, even with no deductions.
|
|
384
|
+
|
|
385
|
+
This is used to indicate that a student has been graded (with a perfect score)
|
|
386
|
+
rather than being absent from the file (not yet graded).
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
net_ids: Tuple of net_ids for the student.
|
|
390
|
+
"""
|
|
391
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
392
|
+
if student_key not in self.deductions_by_students:
|
|
393
|
+
self.deductions_by_students[student_key] = []
|
|
394
|
+
self._save()
|
|
395
|
+
|
|
396
|
+
def is_student_graded(self, net_ids: tuple) -> bool:
|
|
397
|
+
"""Check if a student has been graded (is in the deductions file).
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
net_ids: Tuple of net_ids for the student.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
True if the student is in the deductions file (graded), False otherwise.
|
|
404
|
+
"""
|
|
405
|
+
student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
|
|
406
|
+
return student_key in self.deductions_by_students
|
|
407
|
+
|
|
359
408
|
def set_days_late(self, net_ids: tuple, days_late: int):
|
|
360
409
|
"""Set the number of days late for a student.
|
|
361
410
|
|
|
@@ -7,18 +7,20 @@ from typing import Dict
|
|
|
7
7
|
import pandas
|
|
8
8
|
|
|
9
9
|
from .deductions import StudentDeductions
|
|
10
|
-
from .grading_item_config import
|
|
10
|
+
from .grading_item_config import LearningSuiteColumn
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def generate_feedback_zip(
|
|
14
14
|
yaml_path: pathlib.Path,
|
|
15
|
+
class_list_csv_path: pathlib.Path,
|
|
15
16
|
subitem_feedback_paths: Dict[str, pathlib.Path],
|
|
16
17
|
output_zip_path: pathlib.Path = None,
|
|
17
18
|
) -> pathlib.Path:
|
|
18
19
|
"""Generate a zip file containing feedback files for each student.
|
|
19
20
|
|
|
20
21
|
Args:
|
|
21
|
-
yaml_path: Path to the YAML file that can be loaded by
|
|
22
|
+
yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
|
|
23
|
+
class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
|
|
22
24
|
subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
|
|
23
25
|
output_zip_path: Path for the output zip file. If None, defaults to
|
|
24
26
|
<yaml_dir>/feedback.zip.
|
|
@@ -27,7 +29,7 @@ def generate_feedback_zip(
|
|
|
27
29
|
Path to the generated zip file.
|
|
28
30
|
"""
|
|
29
31
|
yaml_path = pathlib.Path(yaml_path)
|
|
30
|
-
|
|
32
|
+
ls_column = LearningSuiteColumn(yaml_path)
|
|
31
33
|
|
|
32
34
|
# Get the lab name from the YAML file's parent directory
|
|
33
35
|
lab_name = yaml_path.stem
|
|
@@ -38,7 +40,7 @@ def generate_feedback_zip(
|
|
|
38
40
|
|
|
39
41
|
# Load all student deductions for each subitem
|
|
40
42
|
subitem_deductions: Dict[str, StudentDeductions] = {}
|
|
41
|
-
for subitem in
|
|
43
|
+
for subitem in ls_column.items:
|
|
42
44
|
if subitem.name in subitem_feedback_paths:
|
|
43
45
|
feedback_path = subitem_feedback_paths[subitem.name]
|
|
44
46
|
if feedback_path.exists():
|
|
@@ -48,15 +50,8 @@ def generate_feedback_zip(
|
|
|
48
50
|
else:
|
|
49
51
|
subitem_deductions[subitem.name] = StudentDeductions()
|
|
50
52
|
|
|
51
|
-
# Load
|
|
52
|
-
|
|
53
|
-
first_subitem = grade_item_config.subitems[0]
|
|
54
|
-
if not first_subitem.csv_path.exists():
|
|
55
|
-
raise FileNotFoundError(
|
|
56
|
-
f"CSV file for subitem '{first_subitem.name}' not found at {first_subitem.csv_path}"
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
students_df = pandas.read_csv(first_subitem.csv_path)
|
|
53
|
+
# Load class list from provided CSV
|
|
54
|
+
students_df = pandas.read_csv(class_list_csv_path)
|
|
60
55
|
|
|
61
56
|
# Create the zip file
|
|
62
57
|
with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
@@ -68,7 +63,7 @@ def generate_feedback_zip(
|
|
|
68
63
|
# Generate feedback content for this student
|
|
69
64
|
feedback_content = _generate_student_feedback(
|
|
70
65
|
student_row=student_row,
|
|
71
|
-
|
|
66
|
+
ls_column=ls_column,
|
|
72
67
|
subitem_deductions=subitem_deductions,
|
|
73
68
|
)
|
|
74
69
|
|
|
@@ -92,14 +87,14 @@ def generate_feedback_zip(
|
|
|
92
87
|
|
|
93
88
|
def _generate_student_feedback(
|
|
94
89
|
student_row: pandas.Series,
|
|
95
|
-
|
|
90
|
+
ls_column: LearningSuiteColumn,
|
|
96
91
|
subitem_deductions: Dict[str, StudentDeductions],
|
|
97
92
|
) -> str:
|
|
98
93
|
"""Generate the feedback text content for a single student.
|
|
99
94
|
|
|
100
95
|
Args:
|
|
101
96
|
student_row: A row from the student DataFrame.
|
|
102
|
-
|
|
97
|
+
ls_column: The LearningSuiteColumn object.
|
|
103
98
|
subitem_deductions: Mapping from subitem name to StudentDeductions.
|
|
104
99
|
|
|
105
100
|
Returns:
|
|
@@ -121,11 +116,11 @@ def _generate_student_feedback(
|
|
|
121
116
|
feedback_col_width = 45
|
|
122
117
|
points_col_width = 15
|
|
123
118
|
|
|
124
|
-
for
|
|
125
|
-
subitem_points_possible =
|
|
119
|
+
for item in ls_column.items:
|
|
120
|
+
subitem_points_possible = item.points
|
|
126
121
|
total_points_possible += subitem_points_possible
|
|
127
122
|
|
|
128
|
-
lines.append(f"{
|
|
123
|
+
lines.append(f"{item.name} ({subitem_points_possible} points)")
|
|
129
124
|
lines.append("-" * 60)
|
|
130
125
|
|
|
131
126
|
# Header row
|
|
@@ -135,8 +130,8 @@ def _generate_student_feedback(
|
|
|
135
130
|
|
|
136
131
|
subitem_points_deducted = 0
|
|
137
132
|
|
|
138
|
-
# Get deductions for this student in this
|
|
139
|
-
student_deductions_obj = subitem_deductions.get(
|
|
133
|
+
# Get deductions for this student in this item
|
|
134
|
+
student_deductions_obj = subitem_deductions.get(item.name)
|
|
140
135
|
if student_deductions_obj:
|
|
141
136
|
# Find the student's deductions (try single net_id first, then tuple)
|
|
142
137
|
student_key = None
|
|
@@ -16,7 +16,7 @@ import pandas
|
|
|
16
16
|
import yaml
|
|
17
17
|
|
|
18
18
|
from . import grades_csv, student_repos, utils
|
|
19
|
-
from .grading_item import GradeItem
|
|
19
|
+
from .grading_item import GradeItem
|
|
20
20
|
from .utils import (
|
|
21
21
|
CallbackFailed,
|
|
22
22
|
TermColors,
|
|
@@ -40,7 +40,7 @@ class Grader:
|
|
|
40
40
|
def __init__(
|
|
41
41
|
self,
|
|
42
42
|
lab_name: str,
|
|
43
|
-
|
|
43
|
+
class_list_csv_path: pathlib.Path,
|
|
44
44
|
work_path: pathlib.Path = pathlib.Path.cwd() / "temp",
|
|
45
45
|
):
|
|
46
46
|
"""
|
|
@@ -48,27 +48,31 @@ class Grader:
|
|
|
48
48
|
----------
|
|
49
49
|
lab_name: str
|
|
50
50
|
Name of the lab/assignment that you are grading (ie. 'lab3').
|
|
51
|
-
This is used for
|
|
52
|
-
|
|
53
|
-
Path to CSV file with
|
|
54
|
-
and last name
|
|
51
|
+
This is used for logging messages and passed back to your callback functions.
|
|
52
|
+
class_list_csv_path: pathlib.Path
|
|
53
|
+
Path to CSV file with class list exported from LearningSuite. You need to export netid, first
|
|
54
|
+
and last name columns.
|
|
55
55
|
work_path: pathlib.Path
|
|
56
56
|
Path to directory where student files will be placed. For example, if you pass in '.', then student
|
|
57
57
|
code would be placed in './lab3'. By default the working path is a "temp" folder created in your working directory.
|
|
58
58
|
"""
|
|
59
59
|
self.lab_name = lab_name
|
|
60
|
-
self.
|
|
60
|
+
self.class_list_csv_path = pathlib.Path(class_list_csv_path).resolve()
|
|
61
61
|
|
|
62
|
-
# Make sure
|
|
63
|
-
if not self.
|
|
64
|
-
error(
|
|
62
|
+
# Make sure class list csv exists and is readable
|
|
63
|
+
if not self.class_list_csv_path.is_file():
|
|
64
|
+
error(
|
|
65
|
+
"class_list_csv_path",
|
|
66
|
+
"(" + str(class_list_csv_path) + ")",
|
|
67
|
+
"does not exist",
|
|
68
|
+
)
|
|
65
69
|
try:
|
|
66
|
-
with open(
|
|
70
|
+
with open(class_list_csv_path, "r", encoding="utf-8"):
|
|
67
71
|
pass
|
|
68
72
|
except PermissionError:
|
|
69
73
|
error(
|
|
70
|
-
"You do not have permissions to
|
|
71
|
-
"(" + str(
|
|
74
|
+
"You do not have permissions to read the class_list_csv_path file",
|
|
75
|
+
"(" + str(class_list_csv_path) + ").",
|
|
72
76
|
"Is this file open and locked?",
|
|
73
77
|
)
|
|
74
78
|
|
|
@@ -78,11 +82,11 @@ class Grader:
|
|
|
78
82
|
|
|
79
83
|
# Read CSV and make sure it isn't empty
|
|
80
84
|
try:
|
|
81
|
-
pandas.read_csv(self.
|
|
85
|
+
pandas.read_csv(self.class_list_csv_path)
|
|
82
86
|
except pandas.errors.EmptyDataError:
|
|
83
87
|
error(
|
|
84
|
-
"Your
|
|
85
|
-
"(" + str(
|
|
88
|
+
"Your class list csv",
|
|
89
|
+
"(" + str(class_list_csv_path) + ")",
|
|
86
90
|
"appears to be empty",
|
|
87
91
|
)
|
|
88
92
|
|
|
@@ -102,29 +106,24 @@ class Grader:
|
|
|
102
106
|
|
|
103
107
|
def add_item_to_grade(
|
|
104
108
|
self,
|
|
105
|
-
|
|
109
|
+
item_name,
|
|
106
110
|
grading_fcn,
|
|
111
|
+
deductions_yaml_path,
|
|
107
112
|
*,
|
|
108
113
|
grading_fcn_args_dict=None,
|
|
109
114
|
max_points=None,
|
|
110
|
-
score_mode=ScoreMode.MANUAL,
|
|
111
|
-
deductions_yaml_path=None,
|
|
112
|
-
help_msg=None,
|
|
113
115
|
):
|
|
114
116
|
"""Add a new item you want to grade.
|
|
115
117
|
|
|
116
118
|
Parameters
|
|
117
119
|
----------
|
|
118
|
-
csv_col_name: str
|
|
119
|
-
The column name from your grading CSV file that you want to grade.
|
|
120
120
|
grading_fcn: Callable
|
|
121
121
|
The callback function that will perform all your grading work. Your callback function will be provided with the following arguments:
|
|
122
122
|
|
|
123
123
|
* lab_name (*str*): This will pass back the lab name you passed to *__init__*.
|
|
124
124
|
Useful if you use the same callback function to grade multiple different assignments.
|
|
125
|
-
|
|
125
|
+
* item_name (*str*): The name of the item being graded (e.g., "answers.txt").
|
|
126
126
|
* student_code_path (*pathlib.Path*): The location where the unzipped/cloned student files are stored.
|
|
127
|
-
* csv_col_name (*str*): The current CSV column being graded.
|
|
128
127
|
* points (*int*): The maximum number of points possible for the item being graded, used for validating the
|
|
129
128
|
grade when prompting the user to input a grade. If your callback function automatically calcuates and
|
|
130
129
|
returns a grade, this argument is ignored.
|
|
@@ -157,38 +156,22 @@ class Grader:
|
|
|
157
156
|
def my_callback(**kw):
|
|
158
157
|
lab_name = kw["lab_name"]
|
|
159
158
|
first_name = kw["first_names"][0]
|
|
159
|
+
deductions_yaml_path: pathlib.Path
|
|
160
|
+
Path to the YAML file for storing deductions and tracking grading status.
|
|
160
161
|
grading_fcn_args_dict: dict
|
|
161
162
|
(Optional) A dictionary of additional arguments that will be passed to your grading function.
|
|
162
163
|
max_points: int
|
|
163
164
|
(Optional) Number of max points for the graded column.
|
|
164
|
-
deductions_yaml_path: pathlib.Path
|
|
165
|
-
(Optional) Path to the YAML file for storing deductions. Required if score_mode is DEDUCTIONS.
|
|
166
|
-
help_msg: str
|
|
167
|
-
(Optional) When the script asks the user for a grade, it will print this message first. This can be a helpful
|
|
168
|
-
reminder to the TAs of a grading rubric, things they should watch out for, etc.
|
|
169
165
|
"""
|
|
170
166
|
# Check data types
|
|
171
167
|
if not isinstance(grading_fcn, Callable):
|
|
172
168
|
error("'grading_fcn' must be a callable function")
|
|
173
169
|
|
|
174
|
-
df = pandas.read_csv(self.grades_csv_path)
|
|
175
|
-
if csv_col_name is not None and csv_col_name not in df:
|
|
176
|
-
error(
|
|
177
|
-
"Provided grade column name",
|
|
178
|
-
"(" + csv_col_name + ")",
|
|
179
|
-
"does not exist in grades_csv_path",
|
|
180
|
-
"(" + str(self.grades_csv_path) + ").",
|
|
181
|
-
"Columns:",
|
|
182
|
-
list(df.columns),
|
|
183
|
-
)
|
|
184
|
-
|
|
185
170
|
item = GradeItem(
|
|
186
171
|
self,
|
|
187
|
-
|
|
188
|
-
grading_fcn,
|
|
189
|
-
max_points,
|
|
190
|
-
help_msg=help_msg,
|
|
191
|
-
score_mode=score_mode,
|
|
172
|
+
item_name,
|
|
173
|
+
fcn=grading_fcn,
|
|
174
|
+
max_points=max_points,
|
|
192
175
|
deductions_yaml_path=deductions_yaml_path,
|
|
193
176
|
fcn_args_dict=grading_fcn_args_dict,
|
|
194
177
|
)
|
|
@@ -197,21 +180,32 @@ class Grader:
|
|
|
197
180
|
)
|
|
198
181
|
self.items.append(item)
|
|
199
182
|
|
|
200
|
-
def
|
|
183
|
+
def add_item_to_grade_from_config(
|
|
201
184
|
self,
|
|
202
|
-
|
|
185
|
+
grade_item_config,
|
|
186
|
+
grading_fcn,
|
|
203
187
|
):
|
|
204
|
-
"""
|
|
188
|
+
"""Add a new item to grade using a GradeItemConfig object.
|
|
189
|
+
|
|
190
|
+
This is a convenience method that extracts all necessary information from
|
|
191
|
+
a GradeItemConfig object and calls add_item_to_grade.
|
|
205
192
|
|
|
206
193
|
Parameters
|
|
207
194
|
----------
|
|
208
|
-
|
|
209
|
-
The
|
|
210
|
-
|
|
211
|
-
|
|
195
|
+
grade_item_config: GradeItemConfig
|
|
196
|
+
The configuration object for the grade item, containing points,
|
|
197
|
+
feedback_path, and other_data.
|
|
198
|
+
grading_fcn: Callable
|
|
199
|
+
The callback function that will perform the grading work.
|
|
200
|
+
See add_item_to_grade for details on the callback function signature.
|
|
212
201
|
"""
|
|
213
|
-
|
|
214
|
-
|
|
202
|
+
self.add_item_to_grade(
|
|
203
|
+
grading_fcn=grading_fcn,
|
|
204
|
+
item_name=grade_item_config.name,
|
|
205
|
+
deductions_yaml_path=grade_item_config.feedback_path,
|
|
206
|
+
grading_fcn_args_dict=grade_item_config.other_data,
|
|
207
|
+
max_points=grade_item_config.points,
|
|
208
|
+
)
|
|
215
209
|
|
|
216
210
|
def set_submission_system_learning_suite(self, zip_path):
|
|
217
211
|
"""
|
|
@@ -461,10 +455,11 @@ class Grader:
|
|
|
461
455
|
)
|
|
462
456
|
|
|
463
457
|
def _get_all_csv_cols_to_grade(self):
|
|
464
|
-
"""Collect all columns that will be graded into a single list
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
458
|
+
"""Collect all columns that will be graded into a single list.
|
|
459
|
+
|
|
460
|
+
Returns empty list since grades are stored in deductions files, not CSV columns.
|
|
461
|
+
"""
|
|
462
|
+
return []
|
|
468
463
|
|
|
469
464
|
def run(self):
|
|
470
465
|
"""Call this to start (or resume) the grading process"""
|
|
@@ -477,7 +472,7 @@ class Grader:
|
|
|
477
472
|
|
|
478
473
|
# Read in CSV and validate. Print # students who need a grade
|
|
479
474
|
student_grades_df = grades_csv.parse_and_check(
|
|
480
|
-
self.
|
|
475
|
+
self.class_list_csv_path, self._get_all_csv_cols_to_grade()
|
|
481
476
|
)
|
|
482
477
|
|
|
483
478
|
# Filter by students who need a grade
|
|
@@ -520,14 +515,13 @@ class Grader:
|
|
|
520
515
|
concated_names = grades_csv.get_concated_names(row)
|
|
521
516
|
|
|
522
517
|
# Check if student/group needs grading
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
]
|
|
518
|
+
num_group_members_need_grade_per_item = [
|
|
519
|
+
item.num_grades_needed_deductions(net_ids) for item in self.items
|
|
520
|
+
]
|
|
527
521
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
522
|
+
if sum(num_group_members_need_grade_per_item) == 0:
|
|
523
|
+
# This student/group is already fully graded
|
|
524
|
+
continue
|
|
531
525
|
|
|
532
526
|
# Print name(s) of who we are grading
|
|
533
527
|
student_work_path = self.work_path / utils.names_to_dir(
|
|
@@ -798,6 +792,7 @@ class Grader:
|
|
|
798
792
|
def _verify_callback_fcn(fcn, item, fcn_extra_args_dict=None):
|
|
799
793
|
callback_args = [
|
|
800
794
|
"lab_name",
|
|
795
|
+
"item_name",
|
|
801
796
|
"student_code_path",
|
|
802
797
|
"run",
|
|
803
798
|
"build",
|
|
@@ -809,10 +804,6 @@ def _verify_callback_fcn(fcn, item, fcn_extra_args_dict=None):
|
|
|
809
804
|
if item.max_points:
|
|
810
805
|
callback_args.append("max_points")
|
|
811
806
|
|
|
812
|
-
# If this is a fcn for a graded item (not a prep-only function), then
|
|
813
|
-
# this argument is required.
|
|
814
|
-
callback_args.append("csv_col_name")
|
|
815
|
-
|
|
816
807
|
callback_args_optional = [
|
|
817
808
|
"section",
|
|
818
809
|
"homework_id",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Manage the grade CSV file"""
|
|
2
2
|
|
|
3
3
|
import pandas
|
|
4
4
|
|
|
@@ -6,14 +6,14 @@ from .student_repos import convert_github_url_format
|
|
|
6
6
|
from .utils import TermColors, error, print_color, warning
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def parse_and_check(
|
|
10
|
-
"""Parse the
|
|
9
|
+
def parse_and_check(class_list_csv_path, csv_cols):
|
|
10
|
+
"""Parse the class list CSV file and check that column names are valid"""
|
|
11
11
|
try:
|
|
12
|
-
grades_df = pandas.read_csv(
|
|
12
|
+
grades_df = pandas.read_csv(class_list_csv_path)
|
|
13
13
|
except pandas.errors.EmptyDataError:
|
|
14
14
|
error(
|
|
15
15
|
"Exception: pandas.errors.EmptyDataError. Is your",
|
|
16
|
-
|
|
16
|
+
class_list_csv_path.name,
|
|
17
17
|
"file empty?",
|
|
18
18
|
)
|
|
19
19
|
check_csv_column_names(grades_df, csv_cols)
|