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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
5
5
  Home-page: https://github.com/byu-cpe/ygrader
6
6
  Author: Jeff Goeders
@@ -4,7 +4,7 @@ setup(
4
4
  name="ygrader",
5
5
  packages=["ygrader"],
6
6
  package_data={"ygrader": ["*.ahk"]},
7
- version="1.2.1",
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 .sub_items import generate_subitem_csvs, ParentItem
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 parent items and sub-items in the grading system."""
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 ParentItem:
13
- """Represents a parent item in the grading system."""
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(SubItem(yaml_path.parent, name, points, other_data))
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 SubItem:
78
- """Represents a sub-item in the grading system."""
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
- parent_item = ParentItem(item_yaml_path)
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, [parent_item.csv_col_name])
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 parent_item.subitems:
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
5
5
  Home-page: https://github.com/byu-cpe/ygrader
6
6
  Author: Jeff Goeders
@@ -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