ygrader 1.2.1__tar.gz → 1.2.2__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.1/ygrader.egg-info → ygrader-1.2.2}/PKG-INFO +1 -1
- {ygrader-1.2.1 → ygrader-1.2.2}/setup.py +1 -1
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/__init__.py +2 -1
- ygrader-1.2.2/ygrader/feedback.py +223 -0
- ygrader-1.2.1/ygrader/sub_items.py → ygrader-1.2.2/ygrader/grading_item_config.py +9 -9
- {ygrader-1.2.1 → ygrader-1.2.2/ygrader.egg-info}/PKG-INFO +1 -1
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader.egg-info/SOURCES.txt +2 -1
- {ygrader-1.2.1 → ygrader-1.2.2}/LICENSE +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/setup.cfg +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/test/test_interactive.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/test/test_unittest.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/deductions.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/grader.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/grades_csv.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/grading_item.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/score_input.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/student_repos.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/upstream_merger.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader/utils.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-1.2.1 → ygrader-1.2.2}/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="1.2.
|
|
7
|
+
version="1.2.2",
|
|
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",
|
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
from .grader import Grader, CodeSource, ScoreMode
|
|
4
4
|
from .upstream_merger import UpstreamMerger
|
|
5
5
|
from .utils import CallbackFailed
|
|
6
|
-
from .
|
|
6
|
+
from .grading_item_config import generate_subitem_csvs, GradeItemConfig
|
|
7
|
+
from .feedback import generate_feedback_zip
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Module for generating student feedback files."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import zipfile
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
import pandas
|
|
8
|
+
|
|
9
|
+
from .deductions import StudentDeductions
|
|
10
|
+
from .grading_item_config import GradeItemConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_feedback_zip(
|
|
14
|
+
yaml_path: pathlib.Path,
|
|
15
|
+
subitem_feedback_paths: Dict[str, pathlib.Path],
|
|
16
|
+
output_zip_path: pathlib.Path = None,
|
|
17
|
+
) -> pathlib.Path:
|
|
18
|
+
"""Generate a zip file containing feedback files for each student.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
yaml_path: Path to the YAML file that can be loaded by GradeItemConfig.
|
|
22
|
+
subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
|
|
23
|
+
output_zip_path: Path for the output zip file. If None, defaults to
|
|
24
|
+
<yaml_dir>/feedback.zip.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Path to the generated zip file.
|
|
28
|
+
"""
|
|
29
|
+
yaml_path = pathlib.Path(yaml_path)
|
|
30
|
+
grade_item_config = GradeItemConfig(yaml_path)
|
|
31
|
+
|
|
32
|
+
# Get the lab name from the YAML file's parent directory
|
|
33
|
+
lab_name = yaml_path.stem
|
|
34
|
+
|
|
35
|
+
# Default output path
|
|
36
|
+
if output_zip_path is None:
|
|
37
|
+
output_zip_path = yaml_path.parent / "feedback.zip"
|
|
38
|
+
|
|
39
|
+
# Load all student deductions for each subitem
|
|
40
|
+
subitem_deductions: Dict[str, StudentDeductions] = {}
|
|
41
|
+
for subitem in grade_item_config.subitems:
|
|
42
|
+
if subitem.name in subitem_feedback_paths:
|
|
43
|
+
feedback_path = subitem_feedback_paths[subitem.name]
|
|
44
|
+
if feedback_path.exists():
|
|
45
|
+
subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
|
|
46
|
+
else:
|
|
47
|
+
subitem_deductions[subitem.name] = StudentDeductions()
|
|
48
|
+
else:
|
|
49
|
+
subitem_deductions[subitem.name] = StudentDeductions()
|
|
50
|
+
|
|
51
|
+
# Load the first subitem's CSV to get the list of students
|
|
52
|
+
# (all subitems should have the same students)
|
|
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)
|
|
60
|
+
|
|
61
|
+
# Create the zip file
|
|
62
|
+
with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
63
|
+
for _, student_row in students_df.iterrows():
|
|
64
|
+
first_name = str(student_row["First Name"]).strip()
|
|
65
|
+
last_name = str(student_row["Last Name"]).strip()
|
|
66
|
+
net_id = str(student_row["Net ID"]).strip()
|
|
67
|
+
|
|
68
|
+
# Generate feedback content for this student
|
|
69
|
+
feedback_content = _generate_student_feedback(
|
|
70
|
+
student_row=student_row,
|
|
71
|
+
grade_item_config=grade_item_config,
|
|
72
|
+
subitem_deductions=subitem_deductions,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Generate filename
|
|
76
|
+
filename = (
|
|
77
|
+
first_name
|
|
78
|
+
+ "_"
|
|
79
|
+
+ last_name
|
|
80
|
+
+ "_"
|
|
81
|
+
+ net_id
|
|
82
|
+
+ "_feedback-"
|
|
83
|
+
+ lab_name
|
|
84
|
+
+ ".txt"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Add to zip file
|
|
88
|
+
zip_file.writestr(filename, feedback_content)
|
|
89
|
+
|
|
90
|
+
return output_zip_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _generate_student_feedback(
|
|
94
|
+
student_row: pandas.Series,
|
|
95
|
+
grade_item_config: GradeItemConfig,
|
|
96
|
+
subitem_deductions: Dict[str, StudentDeductions],
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Generate the feedback text content for a single student.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
student_row: A row from the student DataFrame.
|
|
102
|
+
grade_item_config: The GradeItemConfig object.
|
|
103
|
+
subitem_deductions: Mapping from subitem name to StudentDeductions.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The formatted feedback text.
|
|
107
|
+
"""
|
|
108
|
+
net_id = str(student_row["Net ID"]).strip()
|
|
109
|
+
first_name = str(student_row["First Name"]).strip()
|
|
110
|
+
last_name = str(student_row["Last Name"]).strip()
|
|
111
|
+
|
|
112
|
+
lines = []
|
|
113
|
+
lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
|
|
114
|
+
lines.append("=" * 60)
|
|
115
|
+
lines.append("")
|
|
116
|
+
|
|
117
|
+
total_points_possible = 0
|
|
118
|
+
total_points_deducted = 0
|
|
119
|
+
|
|
120
|
+
# Column widths for formatting
|
|
121
|
+
feedback_col_width = 45
|
|
122
|
+
points_col_width = 15
|
|
123
|
+
|
|
124
|
+
for subitem in grade_item_config.subitems:
|
|
125
|
+
subitem_points_possible = subitem.points
|
|
126
|
+
total_points_possible += subitem_points_possible
|
|
127
|
+
|
|
128
|
+
lines.append(f"{subitem.name} ({subitem_points_possible} points)")
|
|
129
|
+
lines.append("-" * 60)
|
|
130
|
+
|
|
131
|
+
# Header row
|
|
132
|
+
header = f"{'Feedback':<{feedback_col_width}} {'Points Deducted':>{points_col_width}}"
|
|
133
|
+
lines.append(header)
|
|
134
|
+
lines.append("-" * 60)
|
|
135
|
+
|
|
136
|
+
subitem_points_deducted = 0
|
|
137
|
+
|
|
138
|
+
# Get deductions for this student in this subitem
|
|
139
|
+
student_deductions_obj = subitem_deductions.get(subitem.name)
|
|
140
|
+
if student_deductions_obj:
|
|
141
|
+
# Find the student's deductions (try single net_id first, then tuple)
|
|
142
|
+
student_key = None
|
|
143
|
+
if (net_id,) in student_deductions_obj.deductions_by_students:
|
|
144
|
+
student_key = (net_id,)
|
|
145
|
+
else:
|
|
146
|
+
# Check for multi-student keys containing this net_id
|
|
147
|
+
for key in student_deductions_obj.deductions_by_students.keys():
|
|
148
|
+
if net_id in key:
|
|
149
|
+
student_key = key
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
if student_key:
|
|
153
|
+
deductions = student_deductions_obj.deductions_by_students[student_key]
|
|
154
|
+
for deduction in deductions:
|
|
155
|
+
feedback_text = deduction.message
|
|
156
|
+
points = deduction.points
|
|
157
|
+
|
|
158
|
+
# Wrap long feedback text
|
|
159
|
+
wrapped_lines = _wrap_text(feedback_text, feedback_col_width)
|
|
160
|
+
for i, line_text in enumerate(wrapped_lines):
|
|
161
|
+
if i == 0:
|
|
162
|
+
row = f"{line_text:<{feedback_col_width}} {points:>{points_col_width}.1f}"
|
|
163
|
+
else:
|
|
164
|
+
row = f"{line_text:<{feedback_col_width}} {'':{points_col_width}}"
|
|
165
|
+
lines.append(row)
|
|
166
|
+
|
|
167
|
+
subitem_points_deducted += points
|
|
168
|
+
|
|
169
|
+
if subitem_points_deducted == 0:
|
|
170
|
+
lines.append(
|
|
171
|
+
f"{'No deductions':<{feedback_col_width}} {'0.0':>{points_col_width}}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
lines.append("-" * 60)
|
|
175
|
+
subitem_score = subitem_points_possible - subitem_points_deducted
|
|
176
|
+
lines.append(
|
|
177
|
+
f"{'Subitem Total:':<{feedback_col_width}} {subitem_score:>{points_col_width}.1f} / {subitem_points_possible:.1f}"
|
|
178
|
+
)
|
|
179
|
+
lines.append("")
|
|
180
|
+
|
|
181
|
+
total_points_deducted += subitem_points_deducted
|
|
182
|
+
|
|
183
|
+
# Total score section
|
|
184
|
+
lines.append("=" * 60)
|
|
185
|
+
total_score = total_points_possible - total_points_deducted
|
|
186
|
+
lines.append(
|
|
187
|
+
f"{'TOTAL SCORE:':<{feedback_col_width}} {total_score:>{points_col_width}.1f} / {total_points_possible:.1f}"
|
|
188
|
+
)
|
|
189
|
+
lines.append("=" * 60)
|
|
190
|
+
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _wrap_text(text: str, width: int) -> list:
|
|
195
|
+
"""Wrap text to fit within a given width.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
text: The text to wrap.
|
|
199
|
+
width: Maximum width for each line.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of wrapped lines.
|
|
203
|
+
"""
|
|
204
|
+
if len(text) <= width:
|
|
205
|
+
return [text]
|
|
206
|
+
|
|
207
|
+
words = text.split()
|
|
208
|
+
lines = []
|
|
209
|
+
current_line = ""
|
|
210
|
+
|
|
211
|
+
for word in words:
|
|
212
|
+
if not current_line:
|
|
213
|
+
current_line = word
|
|
214
|
+
elif len(current_line) + 1 + len(word) <= width:
|
|
215
|
+
current_line += " " + word
|
|
216
|
+
else:
|
|
217
|
+
lines.append(current_line)
|
|
218
|
+
current_line = word
|
|
219
|
+
|
|
220
|
+
if current_line:
|
|
221
|
+
lines.append(current_line)
|
|
222
|
+
|
|
223
|
+
return lines if lines else [""]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Module for handling
|
|
1
|
+
"""Module for handling grade item configs and grade subitem configs in the grading system."""
|
|
2
2
|
|
|
3
3
|
import pathlib
|
|
4
4
|
|
|
@@ -9,8 +9,8 @@ from . import grades_csv
|
|
|
9
9
|
from .utils import sanitize_filename
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
class
|
|
13
|
-
"""Represents a
|
|
12
|
+
class GradeItemConfig:
|
|
13
|
+
"""Represents a grade column configuration in the grading system, with a one-to-one mapping to a LearningSuite column"""
|
|
14
14
|
|
|
15
15
|
def __init__(self, yaml_path: pathlib.Path):
|
|
16
16
|
self.subitems = []
|
|
@@ -59,7 +59,7 @@ class ParentItem:
|
|
|
59
59
|
other_data = {
|
|
60
60
|
k: v for k, v in subitem_data.items() if k not in ("name", "points")
|
|
61
61
|
}
|
|
62
|
-
self.subitems.append(
|
|
62
|
+
self.subitems.append(GradeSubitemConfig(yaml_path.parent, name, points, other_data))
|
|
63
63
|
|
|
64
64
|
# Parse any other data in the YAML file beyond 'parent_column' and 'columns'
|
|
65
65
|
self.other_data = {
|
|
@@ -74,8 +74,8 @@ class ParentItem:
|
|
|
74
74
|
raise ValueError(f"Sub-item with name '{name}' not found.")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
class
|
|
78
|
-
"""Represents a
|
|
77
|
+
class GradeSubitemConfig:
|
|
78
|
+
"""Represents a grade subitem configuration in the grading system."""
|
|
79
79
|
|
|
80
80
|
def __init__(
|
|
81
81
|
self, dir_path: pathlib.Path, name: str, points: float, other_data: dict = None
|
|
@@ -90,17 +90,17 @@ class SubItem:
|
|
|
90
90
|
def generate_subitem_csvs(grades_csv_path, item_yaml_path) -> None:
|
|
91
91
|
"""Generate CSV files for sub-items based on the provided item YAML path."""
|
|
92
92
|
item_yaml_path = pathlib.Path(item_yaml_path)
|
|
93
|
-
|
|
93
|
+
grade_item_config = GradeItemConfig(item_yaml_path)
|
|
94
94
|
# grader = Grader("", grades_csv_path)
|
|
95
95
|
|
|
96
|
-
grades_df = grades_csv.parse_and_check(grades_csv_path, [
|
|
96
|
+
grades_df = grades_csv.parse_and_check(grades_csv_path, [grade_item_config.csv_col_name])
|
|
97
97
|
|
|
98
98
|
# Create subitems subdirectory
|
|
99
99
|
subitems_dir = item_yaml_path.parent / "subitems"
|
|
100
100
|
subitems_dir.mkdir(exist_ok=True)
|
|
101
101
|
|
|
102
102
|
overwrite_all = False
|
|
103
|
-
for subitem in
|
|
103
|
+
for subitem in grade_item_config.subitems:
|
|
104
104
|
subitem_csv_path = subitems_dir / f"{subitem.filename}.csv"
|
|
105
105
|
|
|
106
106
|
# Check if file already exists and ask user if they want to overwrite
|
|
@@ -4,13 +4,14 @@ test/test_interactive.py
|
|
|
4
4
|
test/test_unittest.py
|
|
5
5
|
ygrader/__init__.py
|
|
6
6
|
ygrader/deductions.py
|
|
7
|
+
ygrader/feedback.py
|
|
7
8
|
ygrader/grader.py
|
|
8
9
|
ygrader/grades_csv.py
|
|
9
10
|
ygrader/grading_item.py
|
|
11
|
+
ygrader/grading_item_config.py
|
|
10
12
|
ygrader/score_input.py
|
|
11
13
|
ygrader/send_ctrl_backtick.ahk
|
|
12
14
|
ygrader/student_repos.py
|
|
13
|
-
ygrader/sub_items.py
|
|
14
15
|
ygrader/upstream_merger.py
|
|
15
16
|
ygrader/utils.py
|
|
16
17
|
ygrader.egg-info/PKG-INFO
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|