ygrader 1.2.4__tar.gz → 2.1.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.
Files changed (27) hide show
  1. {ygrader-1.2.4/ygrader.egg-info → ygrader-2.1.0}/PKG-INFO +1 -1
  2. {ygrader-1.2.4 → ygrader-2.1.0}/setup.py +1 -1
  3. {ygrader-1.2.4 → ygrader-2.1.0}/test/test_interactive.py +0 -2
  4. ygrader-2.1.0/test/test_unittest.py +139 -0
  5. ygrader-2.1.0/ygrader/__init__.py +10 -0
  6. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/deductions.py +51 -2
  7. ygrader-2.1.0/ygrader/feedback.py +383 -0
  8. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/grader.py +61 -70
  9. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/grades_csv.py +5 -5
  10. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/grading_item.py +80 -127
  11. ygrader-2.1.0/ygrader/grading_item_config.py +95 -0
  12. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/score_input.py +3 -16
  13. {ygrader-1.2.4 → ygrader-2.1.0/ygrader.egg-info}/PKG-INFO +1 -1
  14. ygrader-1.2.4/test/test_unittest.py +0 -105
  15. ygrader-1.2.4/ygrader/__init__.py +0 -7
  16. ygrader-1.2.4/ygrader/feedback.py +0 -223
  17. ygrader-1.2.4/ygrader/grading_item_config.py +0 -129
  18. {ygrader-1.2.4 → ygrader-2.1.0}/LICENSE +0 -0
  19. {ygrader-1.2.4 → ygrader-2.1.0}/setup.cfg +0 -0
  20. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/send_ctrl_backtick.ahk +0 -0
  21. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/student_repos.py +0 -0
  22. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/upstream_merger.py +0 -0
  23. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader/utils.py +0 -0
  24. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/SOURCES.txt +0 -0
  25. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/dependency_links.txt +0 -0
  26. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/requires.txt +0 -0
  27. {ygrader-1.2.4 → ygrader-2.1.0}/ygrader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 1.2.4
3
+ Version: 2.1.0
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.4",
7
+ version="2.1.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)]
@@ -0,0 +1,10 @@
1
+ """Default imports for package"""
2
+
3
+ from .grader import Grader, CodeSource
4
+ from .upstream_merger import UpstreamMerger
5
+ from .utils import CallbackFailed
6
+ from .grading_item_config import (
7
+ LearningSuiteColumn,
8
+ LearningSuiteColumnParseError,
9
+ )
10
+ from .feedback import assemble_grades
@@ -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
- if all_student_keys:
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 all_student_keys:
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
 
@@ -0,0 +1,383 @@
1
+ """Module for generating student feedback files and grades CSV."""
2
+
3
+ import pathlib
4
+ import zipfile
5
+ from typing import Callable, Dict, Optional, Tuple
6
+
7
+ import pandas
8
+
9
+ from .deductions import StudentDeductions
10
+ from .grading_item_config import LearningSuiteColumn
11
+ from .utils import warning
12
+
13
+
14
+ # Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
15
+ LatePenaltyCallback = Callable[[int, float, float], float]
16
+
17
+
18
+ def _get_student_key_and_max_late_days(
19
+ net_id: str,
20
+ item_deductions: Dict[str, StudentDeductions],
21
+ ) -> tuple:
22
+ """Find the student key and maximum late days across all items.
23
+
24
+ Args:
25
+ net_id: The student's net ID.
26
+ item_deductions: Mapping from item name to StudentDeductions.
27
+
28
+ Returns:
29
+ Tuple of (student_key or None, max_late_days).
30
+ """
31
+ max_late_days = 0
32
+ found_student_key = None
33
+
34
+ for deductions_obj in item_deductions.values():
35
+ if not deductions_obj:
36
+ continue
37
+
38
+ # Find the student key
39
+ student_key = None
40
+ if (net_id,) in deductions_obj.deductions_by_students:
41
+ student_key = (net_id,)
42
+ elif (net_id,) in deductions_obj.days_late_by_students:
43
+ student_key = (net_id,)
44
+ else:
45
+ # Check for multi-student keys containing this net_id
46
+ for key in set(deductions_obj.deductions_by_students.keys()) | set(
47
+ deductions_obj.days_late_by_students.keys()
48
+ ):
49
+ if net_id in key:
50
+ student_key = key
51
+ break
52
+
53
+ if student_key:
54
+ found_student_key = student_key
55
+ days_late = deductions_obj.days_late_by_students.get(student_key, 0)
56
+ max_late_days = max(max_late_days, days_late)
57
+
58
+ return found_student_key, max_late_days
59
+
60
+
61
+ def _calculate_student_score(
62
+ net_id: str,
63
+ ls_column: LearningSuiteColumn,
64
+ item_deductions: Dict[str, StudentDeductions],
65
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
66
+ warn_on_missing_callback: bool = True,
67
+ ) -> Tuple[float, float, int]:
68
+ """Calculate a student's final score.
69
+
70
+ Args:
71
+ net_id: The student's net ID.
72
+ ls_column: The LearningSuiteColumn configuration.
73
+ item_deductions: Mapping from item name to StudentDeductions.
74
+ late_penalty_callback: Optional callback for late penalty.
75
+ warn_on_missing_callback: Whether to warn if late days found but no callback.
76
+
77
+ Returns:
78
+ Tuple of (final_score, total_possible, max_late_days).
79
+ """
80
+ total_possible = sum(item.points for item in ls_column.items)
81
+ total_deductions = 0.0
82
+
83
+ for item in ls_column.items:
84
+ deductions_obj = item_deductions.get(item.name)
85
+ if deductions_obj:
86
+ # Find the student's deductions
87
+ student_key = None
88
+ if (net_id,) in deductions_obj.deductions_by_students:
89
+ student_key = (net_id,)
90
+ else:
91
+ for key in deductions_obj.deductions_by_students.keys():
92
+ if net_id in key:
93
+ student_key = key
94
+ break
95
+
96
+ if student_key:
97
+ deductions = deductions_obj.deductions_by_students[student_key]
98
+ for deduction in deductions:
99
+ total_deductions += deduction.points
100
+
101
+ # Calculate score before late penalty
102
+ score = max(0, total_possible - total_deductions)
103
+
104
+ # Get max late days
105
+ _, max_late_days = _get_student_key_and_max_late_days(net_id, item_deductions)
106
+
107
+ # Apply late penalty if applicable
108
+ if max_late_days > 0:
109
+ if late_penalty_callback:
110
+ score = max(0, late_penalty_callback(max_late_days, total_possible, score))
111
+ elif warn_on_missing_callback:
112
+ warning(
113
+ f"Student {net_id} has {max_late_days} late day(s) but no late penalty callback provided"
114
+ )
115
+
116
+ return score, total_possible, max_late_days
117
+
118
+
119
+ def assemble_grades(
120
+ yaml_path: pathlib.Path,
121
+ class_list_csv_path: pathlib.Path,
122
+ subitem_feedback_paths: Dict[str, pathlib.Path],
123
+ *,
124
+ output_zip_path: Optional[pathlib.Path] = None,
125
+ output_csv_path: Optional[pathlib.Path] = None,
126
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
127
+ ) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
128
+ """Generate feedback zip and/or grades CSV from deductions.
129
+
130
+ Args:
131
+ yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
132
+ class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
133
+ subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
134
+ output_zip_path: Path for the output zip file. If None, no zip is generated.
135
+ output_csv_path: Path for the output CSV file. If None, no CSV is generated.
136
+ late_penalty_callback: Optional callback function that takes
137
+ (late_days, max_score, actual_score) and returns the adjusted score.
138
+
139
+ Returns:
140
+ Tuple of (feedback_zip_path or None, grades_csv_path or None).
141
+ """
142
+ yaml_path = pathlib.Path(yaml_path)
143
+ ls_column = LearningSuiteColumn(yaml_path)
144
+
145
+ # Get the lab name from the YAML file's parent directory
146
+ lab_name = yaml_path.parent.name
147
+
148
+ # Load all student deductions for each subitem
149
+ subitem_deductions: Dict[str, StudentDeductions] = {}
150
+ for subitem in ls_column.items:
151
+ if subitem.name in subitem_feedback_paths:
152
+ feedback_path = subitem_feedback_paths[subitem.name]
153
+ if feedback_path.exists():
154
+ subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
155
+ else:
156
+ subitem_deductions[subitem.name] = StudentDeductions()
157
+ else:
158
+ subitem_deductions[subitem.name] = StudentDeductions()
159
+
160
+ # Load class list
161
+ students_df = pandas.read_csv(class_list_csv_path)
162
+
163
+ # Prepare for CSV output
164
+ grades_data = []
165
+
166
+ def process_students(zip_file: Optional[zipfile.ZipFile]) -> None:
167
+ """Process all students, adding to grades_data and optionally writing to zip."""
168
+ for _, student_row in students_df.iterrows():
169
+ first_name = str(student_row["First Name"]).strip()
170
+ last_name = str(student_row["Last Name"]).strip()
171
+ net_id = str(student_row["Net ID"]).strip()
172
+
173
+ # Calculate final score (only warn once, when generating CSV)
174
+ final_score, _, _ = _calculate_student_score(
175
+ net_id=net_id,
176
+ ls_column=ls_column,
177
+ item_deductions=subitem_deductions,
178
+ late_penalty_callback=late_penalty_callback,
179
+ warn_on_missing_callback=(output_csv_path is not None),
180
+ )
181
+
182
+ # Add to grades data
183
+ if output_csv_path:
184
+ grades_data.append(
185
+ {"Net ID": net_id, ls_column.csv_col_name: final_score}
186
+ )
187
+
188
+ # Generate feedback file
189
+ if zip_file:
190
+ feedback_content = _generate_student_feedback(
191
+ student_row=student_row,
192
+ ls_column=ls_column,
193
+ subitem_deductions=subitem_deductions,
194
+ late_penalty_callback=late_penalty_callback,
195
+ )
196
+
197
+ filename = (
198
+ first_name
199
+ + "_"
200
+ + last_name
201
+ + "_"
202
+ + net_id
203
+ + "_feedback-"
204
+ + lab_name
205
+ + ".txt"
206
+ )
207
+ zip_file.writestr(filename, feedback_content)
208
+
209
+ # Process students with or without zip file
210
+ if output_zip_path:
211
+ with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
212
+ process_students(zf)
213
+ else:
214
+ process_students(None)
215
+
216
+ # Write CSV (sorted by Net ID for easier git diffs)
217
+ if output_csv_path and grades_data:
218
+ grades_df = pandas.DataFrame(grades_data)
219
+ grades_df = grades_df.sort_values("Net ID")
220
+ grades_df.to_csv(output_csv_path, index=False)
221
+
222
+ return (output_zip_path, output_csv_path)
223
+
224
+
225
+ def _generate_student_feedback(
226
+ student_row: pandas.Series,
227
+ ls_column: LearningSuiteColumn,
228
+ subitem_deductions: Dict[str, StudentDeductions],
229
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
230
+ ) -> str:
231
+ """Generate the feedback text content for a single student.
232
+
233
+ Args:
234
+ student_row: A row from the student DataFrame.
235
+ ls_column: The LearningSuiteColumn object.
236
+ subitem_deductions: Mapping from subitem name to StudentDeductions.
237
+ late_penalty_callback: Optional callback for calculating late penalty.
238
+
239
+ Returns:
240
+ The formatted feedback text.
241
+ """
242
+ net_id = str(student_row["Net ID"]).strip()
243
+ first_name = str(student_row["First Name"]).strip()
244
+ last_name = str(student_row["Last Name"]).strip()
245
+
246
+ lines = []
247
+ lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
248
+ lines.append("=" * 60)
249
+ lines.append("")
250
+
251
+ total_points_possible = 0
252
+ total_points_deducted = 0
253
+
254
+ # Column widths for formatting
255
+ # Total width is 60 characters to match separator lines
256
+ # Item name column + score column (e.g., "10.0 / 10.0")
257
+ item_col_width = 42
258
+ score_col_width = 17
259
+ # For deduction lines: " - " prefix (4 chars) + message + points
260
+ deduction_prefix = " - "
261
+ deduction_msg_width = 38
262
+ deduction_pts_width = 17
263
+
264
+ for item in ls_column.items:
265
+ subitem_points_possible = item.points
266
+ total_points_possible += subitem_points_possible
267
+
268
+ subitem_points_deducted = 0
269
+ item_deductions = []
270
+
271
+ # Get deductions for this student in this item
272
+ student_deductions_obj = subitem_deductions.get(item.name)
273
+ if student_deductions_obj:
274
+ # Find the student's deductions (try single net_id first, then tuple)
275
+ student_key = None
276
+ if (net_id,) in student_deductions_obj.deductions_by_students:
277
+ student_key = (net_id,)
278
+ else:
279
+ # Check for multi-student keys containing this net_id
280
+ for key in student_deductions_obj.deductions_by_students.keys():
281
+ if net_id in key:
282
+ student_key = key
283
+ break
284
+
285
+ if student_key:
286
+ deductions = student_deductions_obj.deductions_by_students[student_key]
287
+ for deduction in deductions:
288
+ item_deductions.append((deduction.message, deduction.points))
289
+ subitem_points_deducted += deduction.points
290
+
291
+ # Calculate item score
292
+ subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
293
+ score_str = f"{subitem_score:.1f} / {subitem_points_possible:.1f}"
294
+
295
+ # Item line with score
296
+ item_name_with_colon = f"{item.name}:"
297
+ lines.append(
298
+ f"{item_name_with_colon:<{item_col_width}} {score_str:>{score_col_width}}"
299
+ )
300
+
301
+ # Deduction lines (indented)
302
+ for msg, pts in item_deductions:
303
+ # Wrap long messages
304
+ wrapped = _wrap_text(msg, deduction_msg_width)
305
+ for i, line_text in enumerate(wrapped):
306
+ if i == 0:
307
+ lines.append(
308
+ f"{deduction_prefix}{line_text:<{deduction_msg_width}} {-pts:>{deduction_pts_width}.1f}"
309
+ )
310
+ else:
311
+ # Continuation lines - no points
312
+ lines.append(f"{deduction_prefix}{line_text}")
313
+
314
+ total_points_deducted += subitem_points_deducted
315
+
316
+ # Calculate score before late penalty (clamped to 0)
317
+ score_before_late = max(0, total_points_possible - total_points_deducted)
318
+
319
+ # Get max late days for this student
320
+ _, max_late_days = _get_student_key_and_max_late_days(net_id, subitem_deductions)
321
+
322
+ # Late penalty section
323
+ lines.append("")
324
+ lines.append("=" * 60)
325
+ if max_late_days > 0 and late_penalty_callback:
326
+ final_score = late_penalty_callback(
327
+ max_late_days, total_points_possible, score_before_late
328
+ )
329
+ # Ensure final score is not negative
330
+ final_score = max(0, final_score)
331
+ late_penalty_points = score_before_late - final_score
332
+ late_label = (
333
+ f"Late Penalty ({max_late_days} day{'s' if max_late_days != 1 else ''}):"
334
+ )
335
+ lines.append(
336
+ f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
337
+ )
338
+ else:
339
+ final_score = score_before_late
340
+ lines.append(
341
+ f"{'Late Penalty:':<{item_col_width}} {'On Time':>{score_col_width}}"
342
+ )
343
+
344
+ # Total score section
345
+ total_score_str = f"{final_score:.1f} / {total_points_possible:.1f}"
346
+ lines.append(
347
+ f"{'TOTAL SCORE:':<{item_col_width}} {total_score_str:>{score_col_width}}"
348
+ )
349
+ lines.append("=" * 60)
350
+
351
+ return "\n".join(lines)
352
+
353
+
354
+ def _wrap_text(text: str, width: int) -> list:
355
+ """Wrap text to fit within a given width.
356
+
357
+ Args:
358
+ text: The text to wrap.
359
+ width: Maximum width for each line.
360
+
361
+ Returns:
362
+ List of wrapped lines.
363
+ """
364
+ if len(text) <= width:
365
+ return [text]
366
+
367
+ words = text.split()
368
+ lines = []
369
+ current_line = ""
370
+
371
+ for word in words:
372
+ if not current_line:
373
+ current_line = word
374
+ elif len(current_line) + 1 + len(word) <= width:
375
+ current_line += " " + word
376
+ else:
377
+ lines.append(current_line)
378
+ current_line = word
379
+
380
+ if current_line:
381
+ lines.append(current_line)
382
+
383
+ return lines if lines else [""]