ygrader 2.0.0__tar.gz → 2.2.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 (24) hide show
  1. {ygrader-2.0.0/ygrader.egg-info → ygrader-2.2.0}/PKG-INFO +1 -1
  2. {ygrader-2.0.0 → ygrader-2.2.0}/setup.py +1 -1
  3. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/__init__.py +1 -1
  4. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/deductions.py +21 -21
  5. ygrader-2.2.0/ygrader/feedback.py +476 -0
  6. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grader.py +0 -48
  7. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grading_item.py +12 -49
  8. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grading_item_config.py +1 -5
  9. {ygrader-2.0.0 → ygrader-2.2.0/ygrader.egg-info}/PKG-INFO +1 -1
  10. ygrader-2.0.0/ygrader/feedback.py +0 -218
  11. {ygrader-2.0.0 → ygrader-2.2.0}/LICENSE +0 -0
  12. {ygrader-2.0.0 → ygrader-2.2.0}/setup.cfg +0 -0
  13. {ygrader-2.0.0 → ygrader-2.2.0}/test/test_interactive.py +0 -0
  14. {ygrader-2.0.0 → ygrader-2.2.0}/test/test_unittest.py +0 -0
  15. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/grades_csv.py +0 -0
  16. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/score_input.py +0 -0
  17. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/send_ctrl_backtick.ahk +0 -0
  18. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/student_repos.py +0 -0
  19. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/upstream_merger.py +0 -0
  20. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader/utils.py +0 -0
  21. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader.egg-info/SOURCES.txt +0 -0
  22. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader.egg-info/dependency_links.txt +0 -0
  23. {ygrader-2.0.0 → ygrader-2.2.0}/ygrader.egg-info/requires.txt +0 -0
  24. {ygrader-2.0.0 → ygrader-2.2.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: 2.0.0
3
+ Version: 2.2.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="2.0.0",
7
+ version="2.2.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",
@@ -7,4 +7,4 @@ from .grading_item_config import (
7
7
  LearningSuiteColumn,
8
8
  LearningSuiteColumnParseError,
9
9
  )
10
- from .feedback import generate_feedback_zip
10
+ from .feedback import assemble_grades
@@ -44,7 +44,7 @@ class StudentDeductions:
44
44
 
45
45
  def __init__(self, yaml_path: Optional[pathlib.Path] = None):
46
46
  self.deductions_by_students = {}
47
- self.days_late_by_students = {}
47
+ self.submit_time_by_students = {} # ISO format timestamp strings
48
48
  self.deduction_types = {}
49
49
  self.yaml_path = yaml_path
50
50
 
@@ -102,9 +102,9 @@ class StudentDeductions:
102
102
 
103
103
  self.deductions_by_students[student_key] = deduction_items
104
104
 
105
- # Load days_late if present
106
- if "days_late" in entry:
107
- self.days_late_by_students[student_key] = entry["days_late"]
105
+ # Load submit_time if present
106
+ if "submit_time" in entry:
107
+ self.submit_time_by_students[student_key] = entry["submit_time"]
108
108
 
109
109
  def _write_yaml(self):
110
110
  """Write deduction types and student deductions to the YAML file.
@@ -134,9 +134,9 @@ class StudentDeductions:
134
134
  )
135
135
  data["deduction_types"] = deduction_list
136
136
 
137
- # Write student deductions (include students with deductions OR days_late)
137
+ # Write student deductions (include students with deductions OR submit_time)
138
138
  all_student_keys = set(self.deductions_by_students.keys()) | set(
139
- self.days_late_by_students.keys()
139
+ self.submit_time_by_students.keys()
140
140
  )
141
141
  # Sort student keys for consistent output ordering
142
142
  sorted_student_keys = sorted(all_student_keys)
@@ -158,8 +158,8 @@ class StudentDeductions:
158
158
  "net_ids": FlowList(student_key),
159
159
  "deductions": FlowList(deduction_ids),
160
160
  **(
161
- {"days_late": self.days_late_by_students[student_key]}
162
- if student_key in self.days_late_by_students
161
+ {"submit_time": self.submit_time_by_students[student_key]}
162
+ if student_key in self.submit_time_by_students
163
163
  else {}
164
164
  ),
165
165
  }
@@ -375,8 +375,8 @@ class StudentDeductions:
375
375
  student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
376
376
  if student_key in self.deductions_by_students:
377
377
  del self.deductions_by_students[student_key]
378
- if student_key in self.days_late_by_students:
379
- del self.days_late_by_students[student_key]
378
+ if student_key in self.submit_time_by_students:
379
+ del self.submit_time_by_students[student_key]
380
380
  self._save()
381
381
 
382
382
  def ensure_student_in_file(self, net_ids: tuple):
@@ -405,31 +405,31 @@ class StudentDeductions:
405
405
  student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
406
406
  return student_key in self.deductions_by_students
407
407
 
408
- def set_days_late(self, net_ids: tuple, days_late: int):
409
- """Set the number of days late for a student.
408
+ def set_submit_time(self, net_ids: tuple, submit_time: Optional[str]):
409
+ """Set the submission time for a student.
410
410
 
411
411
  Args:
412
412
  net_ids: Tuple of net_ids for the student.
413
- days_late: Number of business days late (0 or None to remove).
413
+ submit_time: ISO format timestamp string, or None to remove.
414
414
  """
415
415
  student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
416
- if days_late and days_late > 0:
417
- self.days_late_by_students[student_key] = days_late
418
- elif student_key in self.days_late_by_students:
419
- del self.days_late_by_students[student_key]
416
+ if submit_time:
417
+ self.submit_time_by_students[student_key] = submit_time
418
+ elif student_key in self.submit_time_by_students:
419
+ del self.submit_time_by_students[student_key]
420
420
  self._save()
421
421
 
422
- def get_days_late(self, net_ids: tuple) -> Optional[int]:
423
- """Get the number of days late for a student.
422
+ def get_submit_time(self, net_ids: tuple) -> Optional[str]:
423
+ """Get the submission time for a student.
424
424
 
425
425
  Args:
426
426
  net_ids: Tuple of net_ids for the student.
427
427
 
428
428
  Returns:
429
- Number of business days late, or None if not set/on time.
429
+ ISO format timestamp string, or None if not set.
430
430
  """
431
431
  student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
432
- return self.days_late_by_students.get(student_key)
432
+ return self.submit_time_by_students.get(student_key)
433
433
 
434
434
  def total_deductions(self, net_ids: Optional[tuple] = None) -> float:
435
435
  """Calculate the total deductions for a student or all students.
@@ -0,0 +1,476 @@
1
+ """Module for generating student feedback files and grades CSV."""
2
+
3
+ import datetime
4
+ import pathlib
5
+ import zipfile
6
+ from typing import Callable, Dict, Optional, Tuple
7
+
8
+ import numpy as np
9
+ import pandas
10
+
11
+ from .deductions import StudentDeductions
12
+ from .grading_item_config import LearningSuiteColumn
13
+ from .utils import warning, print_color, TermColors
14
+
15
+
16
+ # Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
17
+ LatePenaltyCallback = Callable[[int, float, float], float]
18
+
19
+
20
+ def _calculate_late_days(
21
+ submit_time_str: Optional[str],
22
+ due_date: datetime.datetime,
23
+ ) -> int:
24
+ """Calculate the number of business days late from a submit time.
25
+
26
+ Args:
27
+ submit_time_str: ISO format timestamp string of submission.
28
+ due_date: The effective due date for the student.
29
+
30
+ Returns:
31
+ Number of business days late (0 if on time or no submit time).
32
+ """
33
+ if not submit_time_str:
34
+ return 0
35
+
36
+ try:
37
+ submit_time = datetime.datetime.fromisoformat(submit_time_str)
38
+ except ValueError:
39
+ return 0
40
+
41
+ if submit_time <= due_date:
42
+ return 0
43
+
44
+ days_late = np.busday_count(due_date.date(), submit_time.date())
45
+ if days_late == 0:
46
+ days_late = 1 # Same day but after deadline
47
+ return int(days_late)
48
+
49
+
50
+ def _get_student_key_and_max_late_days(
51
+ net_id: str,
52
+ item_deductions: Dict[str, StudentDeductions],
53
+ due_date: Optional[datetime.datetime] = None,
54
+ due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
55
+ ) -> tuple:
56
+ """Find the student key and maximum late days across all items.
57
+
58
+ Args:
59
+ net_id: The student's net ID.
60
+ item_deductions: Mapping from item name to StudentDeductions.
61
+ due_date: The default due date for the assignment.
62
+ due_date_exceptions: Mapping from net_id to exception due date.
63
+
64
+ Returns:
65
+ Tuple of (student_key or None, max_late_days).
66
+ """
67
+ max_late_days = 0
68
+ found_student_key = None
69
+
70
+ if due_date_exceptions is None:
71
+ due_date_exceptions = {}
72
+
73
+ for deductions_obj in item_deductions.values():
74
+ if not deductions_obj:
75
+ continue
76
+
77
+ # Find the student key
78
+ student_key = None
79
+ if (net_id,) in deductions_obj.deductions_by_students:
80
+ student_key = (net_id,)
81
+ elif (net_id,) in deductions_obj.submit_time_by_students:
82
+ student_key = (net_id,)
83
+ else:
84
+ # Check for multi-student keys containing this net_id
85
+ for key in set(deductions_obj.deductions_by_students.keys()) | set(
86
+ deductions_obj.submit_time_by_students.keys()
87
+ ):
88
+ if net_id in key:
89
+ student_key = key
90
+ break
91
+
92
+ if student_key:
93
+ found_student_key = student_key
94
+
95
+ # Calculate late days from submit_time if we have a due date
96
+ if due_date is not None:
97
+ submit_time_str = deductions_obj.submit_time_by_students.get(
98
+ student_key
99
+ )
100
+ if submit_time_str:
101
+ # Calculate effective due date (using most generous exception for group)
102
+ effective_due_date = due_date
103
+ for member_net_id in student_key:
104
+ if member_net_id in due_date_exceptions:
105
+ effective_due_date = max(
106
+ effective_due_date, due_date_exceptions[member_net_id]
107
+ )
108
+
109
+ days_late = _calculate_late_days(
110
+ submit_time_str, effective_due_date
111
+ )
112
+ max_late_days = max(max_late_days, days_late)
113
+
114
+ return found_student_key, max_late_days
115
+
116
+
117
+ def _calculate_student_score(
118
+ net_id: str,
119
+ ls_column: LearningSuiteColumn,
120
+ item_deductions: Dict[str, StudentDeductions],
121
+ *,
122
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
123
+ warn_on_missing_callback: bool = True,
124
+ due_date: Optional[datetime.datetime] = None,
125
+ due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
126
+ ) -> Tuple[float, float, int]:
127
+ """Calculate a student's final score.
128
+
129
+ Args:
130
+ net_id: The student's net ID.
131
+ ls_column: The LearningSuiteColumn configuration.
132
+ item_deductions: Mapping from item name to StudentDeductions.
133
+ late_penalty_callback: Optional callback for late penalty.
134
+ warn_on_missing_callback: Whether to warn if late days found but no callback.
135
+ due_date: The default due date for the assignment.
136
+ due_date_exceptions: Mapping from net_id to exception due date.
137
+
138
+ Returns:
139
+ Tuple of (final_score, total_possible, max_late_days).
140
+ """
141
+ total_possible = sum(item.points for item in ls_column.items)
142
+ total_deductions = 0.0
143
+
144
+ for item in ls_column.items:
145
+ deductions_obj = item_deductions.get(item.name)
146
+ if deductions_obj:
147
+ # Find the student's deductions
148
+ student_key = None
149
+ if (net_id,) in deductions_obj.deductions_by_students:
150
+ student_key = (net_id,)
151
+ else:
152
+ for key in deductions_obj.deductions_by_students.keys():
153
+ if net_id in key:
154
+ student_key = key
155
+ break
156
+
157
+ if student_key:
158
+ deductions = deductions_obj.deductions_by_students[student_key]
159
+ for deduction in deductions:
160
+ total_deductions += deduction.points
161
+
162
+ # Calculate score before late penalty
163
+ score = max(0, total_possible - total_deductions)
164
+
165
+ # Get max late days
166
+ _, max_late_days = _get_student_key_and_max_late_days(
167
+ net_id, item_deductions, due_date, due_date_exceptions
168
+ )
169
+
170
+ # Apply late penalty if applicable
171
+ if max_late_days > 0:
172
+ if late_penalty_callback:
173
+ score = max(0, late_penalty_callback(max_late_days, total_possible, score))
174
+ elif warn_on_missing_callback:
175
+ warning(
176
+ f"Student {net_id} has {max_late_days} late day(s) but no late penalty callback provided"
177
+ )
178
+
179
+ return score, total_possible, max_late_days
180
+
181
+
182
+ def assemble_grades(
183
+ yaml_path: pathlib.Path,
184
+ class_list_csv_path: pathlib.Path,
185
+ subitem_feedback_paths: Dict[str, pathlib.Path],
186
+ *,
187
+ output_zip_path: Optional[pathlib.Path] = None,
188
+ output_csv_path: Optional[pathlib.Path] = None,
189
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
190
+ due_date: Optional[datetime.datetime] = None,
191
+ due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
192
+ ) -> Tuple[Optional[pathlib.Path], Optional[pathlib.Path]]:
193
+ """Generate feedback zip and/or grades CSV from deductions.
194
+
195
+ Args:
196
+ yaml_path: Path to the YAML file that can be loaded by LearningSuiteColumn.
197
+ class_list_csv_path: Path to CSV file with class list (Net ID, First Name, Last Name).
198
+ subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
199
+ output_zip_path: Path for the output zip file. If None, no zip is generated.
200
+ output_csv_path: Path for the output CSV file. If None, no CSV is generated.
201
+ late_penalty_callback: Optional callback function that takes
202
+ (late_days, max_score, actual_score) and returns the adjusted score.
203
+ due_date: The default due date for the assignment. Required for late penalty.
204
+ due_date_exceptions: Mapping from net_id to exception due date.
205
+
206
+ Returns:
207
+ Tuple of (feedback_zip_path or None, grades_csv_path or None).
208
+ """
209
+ yaml_path = pathlib.Path(yaml_path)
210
+ ls_column = LearningSuiteColumn(yaml_path)
211
+
212
+ # Get the lab name from the YAML file's parent directory
213
+ lab_name = yaml_path.parent.name
214
+
215
+ # Load all student deductions for each subitem
216
+ subitem_deductions: Dict[str, StudentDeductions] = {}
217
+ for subitem in ls_column.items:
218
+ if subitem.name in subitem_feedback_paths:
219
+ feedback_path = subitem_feedback_paths[subitem.name]
220
+ if feedback_path.exists():
221
+ subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
222
+ else:
223
+ subitem_deductions[subitem.name] = StudentDeductions()
224
+ else:
225
+ subitem_deductions[subitem.name] = StudentDeductions()
226
+
227
+ # Load class list
228
+ students_df = pandas.read_csv(class_list_csv_path)
229
+
230
+ # Prepare for CSV output
231
+ grades_data = []
232
+
233
+ def process_students(zip_file: Optional[zipfile.ZipFile]) -> None:
234
+ """Process all students, adding to grades_data and optionally writing to zip."""
235
+ for _, student_row in students_df.iterrows():
236
+ first_name = str(student_row["First Name"]).strip()
237
+ last_name = str(student_row["Last Name"]).strip()
238
+ net_id = str(student_row["Net ID"]).strip()
239
+
240
+ # Calculate score before late penalty
241
+ score_before_late, total_possible, max_late_days = _calculate_student_score(
242
+ net_id=net_id,
243
+ ls_column=ls_column,
244
+ item_deductions=subitem_deductions,
245
+ late_penalty_callback=None, # Don't apply late penalty yet
246
+ warn_on_missing_callback=False,
247
+ due_date=due_date,
248
+ due_date_exceptions=due_date_exceptions,
249
+ )
250
+
251
+ # Apply late penalty if applicable
252
+ final_score = score_before_late
253
+ if max_late_days > 0 and late_penalty_callback:
254
+ final_score = max(
255
+ 0,
256
+ late_penalty_callback(
257
+ max_late_days, total_possible, score_before_late
258
+ ),
259
+ )
260
+ print_color(
261
+ TermColors.YELLOW,
262
+ f"Late: {net_id} ({max_late_days} day{'s' if max_late_days != 1 else ''}): "
263
+ f"{score_before_late:.1f} -> {final_score:.1f}",
264
+ )
265
+
266
+ # Add to grades data
267
+ if output_csv_path:
268
+ grades_data.append(
269
+ {"Net ID": net_id, ls_column.csv_col_name: final_score}
270
+ )
271
+
272
+ # Generate feedback file
273
+ if zip_file:
274
+ feedback_content = _generate_student_feedback(
275
+ student_row=student_row,
276
+ ls_column=ls_column,
277
+ subitem_deductions=subitem_deductions,
278
+ late_penalty_callback=late_penalty_callback,
279
+ due_date=due_date,
280
+ due_date_exceptions=due_date_exceptions,
281
+ )
282
+
283
+ filename = (
284
+ first_name
285
+ + "_"
286
+ + last_name
287
+ + "_"
288
+ + net_id
289
+ + "_feedback-"
290
+ + lab_name
291
+ + ".txt"
292
+ )
293
+ zip_file.writestr(filename, feedback_content)
294
+
295
+ # Process students with or without zip file
296
+ if output_zip_path:
297
+ with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
298
+ process_students(zf)
299
+ else:
300
+ process_students(None)
301
+
302
+ # Write CSV (sorted by Net ID for easier git diffs)
303
+ if output_csv_path and grades_data:
304
+ grades_df = pandas.DataFrame(grades_data)
305
+ grades_df = grades_df.sort_values("Net ID")
306
+ grades_df.to_csv(output_csv_path, index=False)
307
+
308
+ return (output_zip_path, output_csv_path)
309
+
310
+
311
+ def _generate_student_feedback(
312
+ student_row: pandas.Series,
313
+ ls_column: LearningSuiteColumn,
314
+ subitem_deductions: Dict[str, StudentDeductions],
315
+ *,
316
+ late_penalty_callback: Optional[LatePenaltyCallback] = None,
317
+ due_date: Optional[datetime.datetime] = None,
318
+ due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
319
+ ) -> str:
320
+ """Generate the feedback text content for a single student.
321
+
322
+ Args:
323
+ student_row: A row from the student DataFrame.
324
+ ls_column: The LearningSuiteColumn object.
325
+ subitem_deductions: Mapping from subitem name to StudentDeductions.
326
+ late_penalty_callback: Optional callback for calculating late penalty.
327
+ due_date: The default due date for the assignment.
328
+ due_date_exceptions: Mapping from net_id to exception due date.
329
+
330
+ Returns:
331
+ The formatted feedback text.
332
+ """
333
+ net_id = str(student_row["Net ID"]).strip()
334
+ first_name = str(student_row["First Name"]).strip()
335
+ last_name = str(student_row["Last Name"]).strip()
336
+
337
+ lines = []
338
+ lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
339
+ lines.append("=" * 60)
340
+ lines.append("")
341
+
342
+ total_points_possible = 0
343
+ total_points_deducted = 0
344
+
345
+ # Column widths for formatting
346
+ # Total width is 60 characters to match separator lines
347
+ # Item name column + score column (e.g., "10.0 / 10.0")
348
+ item_col_width = 42
349
+ score_col_width = 17
350
+ # For deduction lines: " - " prefix (4 chars) + message + points
351
+ deduction_prefix = " - "
352
+ deduction_msg_width = 38
353
+ deduction_pts_width = 17
354
+
355
+ for item in ls_column.items:
356
+ subitem_points_possible = item.points
357
+ total_points_possible += subitem_points_possible
358
+
359
+ subitem_points_deducted = 0
360
+ item_deductions = []
361
+
362
+ # Get deductions for this student in this item
363
+ student_deductions_obj = subitem_deductions.get(item.name)
364
+ if student_deductions_obj:
365
+ # Find the student's deductions (try single net_id first, then tuple)
366
+ student_key = None
367
+ if (net_id,) in student_deductions_obj.deductions_by_students:
368
+ student_key = (net_id,)
369
+ else:
370
+ # Check for multi-student keys containing this net_id
371
+ for key in student_deductions_obj.deductions_by_students.keys():
372
+ if net_id in key:
373
+ student_key = key
374
+ break
375
+
376
+ if student_key:
377
+ deductions = student_deductions_obj.deductions_by_students[student_key]
378
+ for deduction in deductions:
379
+ item_deductions.append((deduction.message, deduction.points))
380
+ subitem_points_deducted += deduction.points
381
+
382
+ # Calculate item score
383
+ subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
384
+ score_str = f"{subitem_score:.1f} / {subitem_points_possible:.1f}"
385
+
386
+ # Item line with score
387
+ item_name_with_colon = f"{item.name}:"
388
+ lines.append(
389
+ f"{item_name_with_colon:<{item_col_width}} {score_str:>{score_col_width}}"
390
+ )
391
+
392
+ # Deduction lines (indented)
393
+ for msg, pts in item_deductions:
394
+ # Wrap long messages
395
+ wrapped = _wrap_text(msg, deduction_msg_width)
396
+ for i, line_text in enumerate(wrapped):
397
+ if i == 0:
398
+ lines.append(
399
+ f"{deduction_prefix}{line_text:<{deduction_msg_width}} {-pts:>{deduction_pts_width}.1f}"
400
+ )
401
+ else:
402
+ # Continuation lines - no points
403
+ lines.append(f"{deduction_prefix}{line_text}")
404
+
405
+ total_points_deducted += subitem_points_deducted
406
+
407
+ # Calculate score before late penalty (clamped to 0)
408
+ score_before_late = max(0, total_points_possible - total_points_deducted)
409
+
410
+ # Get max late days for this student
411
+ _, max_late_days = _get_student_key_and_max_late_days(
412
+ net_id, subitem_deductions, due_date, due_date_exceptions
413
+ )
414
+
415
+ # Late penalty section
416
+ lines.append("")
417
+ lines.append("=" * 60)
418
+ if max_late_days > 0 and late_penalty_callback:
419
+ final_score = late_penalty_callback(
420
+ max_late_days, total_points_possible, score_before_late
421
+ )
422
+ # Ensure final score is not negative
423
+ final_score = max(0, final_score)
424
+ late_penalty_points = score_before_late - final_score
425
+ late_label = (
426
+ f"Late Penalty ({max_late_days} day{'s' if max_late_days != 1 else ''}):"
427
+ )
428
+ lines.append(
429
+ f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
430
+ )
431
+ else:
432
+ final_score = score_before_late
433
+ lines.append(
434
+ f"{'Late Penalty:':<{item_col_width}} {'On Time':>{score_col_width}}"
435
+ )
436
+
437
+ # Total score section
438
+ total_score_str = f"{final_score:.1f} / {total_points_possible:.1f}"
439
+ lines.append(
440
+ f"{'TOTAL SCORE:':<{item_col_width}} {total_score_str:>{score_col_width}}"
441
+ )
442
+ lines.append("=" * 60)
443
+
444
+ return "\n".join(lines)
445
+
446
+
447
+ def _wrap_text(text: str, width: int) -> list:
448
+ """Wrap text to fit within a given width.
449
+
450
+ Args:
451
+ text: The text to wrap.
452
+ width: Maximum width for each line.
453
+
454
+ Returns:
455
+ List of wrapped lines.
456
+ """
457
+ if len(text) <= width:
458
+ return [text]
459
+
460
+ words = text.split()
461
+ lines = []
462
+ current_line = ""
463
+
464
+ for word in words:
465
+ if not current_line:
466
+ current_line = word
467
+ elif len(current_line) + 1 + len(word) <= width:
468
+ current_line += " " + word
469
+ else:
470
+ lines.append(current_line)
471
+ current_line = word
472
+
473
+ if current_line:
474
+ lines.append(current_line)
475
+
476
+ return lines if lines else [""]
@@ -1,6 +1,5 @@
1
1
  """Main ygrader module"""
2
2
 
3
- import datetime as dt
4
3
  import enum
5
4
  import inspect
6
5
  import os
@@ -13,7 +12,6 @@ from collections import defaultdict
13
12
  from typing import Callable
14
13
 
15
14
  import pandas
16
- import yaml
17
15
 
18
16
  from . import grades_csv, student_repos, utils
19
17
  from .grading_item import GradeItem
@@ -101,7 +99,6 @@ class Grader:
101
99
  self.github_https = None
102
100
  self.groups_csv_path = None
103
101
  self.groups_csv_col_name = None
104
- self.due_date_exceptions = {}
105
102
  self.set_other_options()
106
103
 
107
104
  def add_item_to_grade(
@@ -335,8 +332,6 @@ class Grader:
335
332
  dry_run_first=False,
336
333
  dry_run_all=False,
337
334
  workflow_hash=None,
338
- due_date=None,
339
- due_date_exceptions_path=None,
340
335
  ):
341
336
  """
342
337
  This can be used to set other options for the grader.
@@ -376,15 +371,6 @@ class Grader:
376
371
  (Optional) Expected hash of the GitHub workflow file. If provided, the workflow file will be verified
377
372
  before grading each student. If the hash doesn't match, a warning will be displayed indicating
378
373
  the student may have modified the workflow system.
379
- due_date: datetime.datetime
380
- (Optional) Due date for the assignment. If provided, the submission date will be compared to this
381
- and late days will be calculated and displayed.
382
- due_date_exceptions_path: str
383
- (Optional) Path to a YAML file containing per-student due date exceptions. The file should be
384
- a simple dictionary mapping net_ids to deadline strings in "YYYY-MM-DD HH:MM:SS" format.
385
- Example:
386
- "student1": "2025-01-15 23:59:59"
387
- "student2": "2025-01-17 23:59:59"
388
374
  """
389
375
  self.format_code = format_code
390
376
  self.build_only = build_only
@@ -392,9 +378,6 @@ class Grader:
392
378
  self.allow_rebuild = allow_rebuild
393
379
  self.allow_rerun = allow_rerun
394
380
  self.workflow_hash = workflow_hash
395
- self.due_date = due_date
396
- self.due_date_exceptions = {}
397
- self.due_date_exceptions_path = due_date_exceptions_path
398
381
  if prep_fcn and not isinstance(prep_fcn, Callable):
399
382
  error("The 'prep_fcn' argument must provide a callable function pointer")
400
383
  self.prep_fcn = prep_fcn
@@ -424,36 +407,6 @@ class Grader:
424
407
  + "set_submission_system_learning_suite() or set_submission_system_github()."
425
408
  )
426
409
 
427
- def _load_due_date_exceptions(self):
428
- """Load due date exceptions from YAML file (simple net_id: deadline format)"""
429
-
430
- self.due_date_exceptions = {}
431
- if not self.due_date_exceptions_path:
432
- return
433
-
434
- try:
435
- with open(self.due_date_exceptions_path, "r", encoding="utf-8") as f:
436
- exceptions_raw = yaml.safe_load(f)
437
- except (IOError, yaml.YAMLError) as e:
438
- print_color(
439
- TermColors.YELLOW, f"Warning: Could not load exceptions file: {e}"
440
- )
441
- return
442
-
443
- if not exceptions_raw or not isinstance(exceptions_raw, dict):
444
- return
445
-
446
- for net_id, deadline_str in exceptions_raw.items():
447
- try:
448
- self.due_date_exceptions[net_id] = dt.datetime.strptime(
449
- deadline_str, "%Y-%m-%d %H:%M:%S"
450
- )
451
- except ValueError as e:
452
- print_color(
453
- TermColors.YELLOW,
454
- f"Warning: Could not parse deadline for {net_id}: {e}",
455
- )
456
-
457
410
  def _get_all_csv_cols_to_grade(self):
458
411
  """Collect all columns that will be graded into a single list.
459
412
 
@@ -465,7 +418,6 @@ class Grader:
465
418
  """Call this to start (or resume) the grading process"""
466
419
 
467
420
  self._validate_config()
468
- self._load_due_date_exceptions()
469
421
 
470
422
  # Print starting message
471
423
  print_color(TermColors.BLUE, "Running grader for", self.lab_name)
@@ -3,8 +3,6 @@
3
3
  import datetime
4
4
  import sys
5
5
 
6
- import numpy as np
7
-
8
6
  from .utils import (
9
7
  CallbackFailed,
10
8
  TermColors,
@@ -149,10 +147,10 @@ class GradeItem:
149
147
  print_color(TermColors.RED, "=" * 70)
150
148
  print("")
151
149
 
152
- # Display submission date if available
150
+ # Display submission date if available and store for later late calculation
153
151
  student_code_path = callback_args.get("student_code_path")
154
- # Calculate days_late but don't save yet - will be saved only after successful grading
155
- pending_days_late = None
152
+ # Store submission time but don't save yet - will be saved only after successful grading
153
+ pending_submit_time = None
156
154
  if student_code_path:
157
155
  submission_date_path = student_code_path / ".commitdate"
158
156
  if submission_date_path.is_file():
@@ -166,43 +164,8 @@ class GradeItem:
166
164
  TermColors.BLUE,
167
165
  f"Submitted: {submission_time.strftime('%Y-%m-%d %H:%M:%S')}",
168
166
  )
169
-
170
- # Calculate late days if due_date is configured
171
- if self.grader.due_date is not None:
172
- # Check for student-specific due date exception
173
- # Use the latest (most generous) exception if multiple group members have them
174
- effective_due_date = self.grader.due_date
175
- for net_id in net_ids:
176
- if net_id in self.grader.due_date_exceptions:
177
- exception_date = self.grader.due_date_exceptions[
178
- net_id
179
- ]
180
- effective_due_date = max(
181
- effective_due_date, exception_date
182
- )
183
-
184
- has_exception = effective_due_date != self.grader.due_date
185
- if has_exception:
186
- print_color(
187
- TermColors.YELLOW,
188
- f"Exception due date: {effective_due_date.strftime('%Y-%m-%d %H:%M:%S')}",
189
- )
190
-
191
- if submission_time <= effective_due_date:
192
- print_color(TermColors.GREEN, "Status: ON TIME")
193
- pending_days_late = 0
194
- else:
195
- days_late = np.busday_count(
196
- effective_due_date.date(),
197
- submission_time.date(),
198
- )
199
- if days_late == 0:
200
- days_late = 1 # Same day but after deadline
201
- print_color(
202
- TermColors.RED,
203
- f"Status: LATE ({days_late} business day(s))",
204
- )
205
- pending_days_late = int(days_late)
167
+ # Store as ISO format for later late calculation
168
+ pending_submit_time = submission_time.isoformat()
206
169
  except (ValueError, IOError) as e:
207
170
  print_color(
208
171
  TermColors.YELLOW,
@@ -230,10 +193,10 @@ class GradeItem:
230
193
  TermColors.BLUE,
231
194
  f"Applied deduction: {deduction_desc} (-{deduction_points})",
232
195
  )
233
- # Save days_late now that grading succeeded
234
- if pending_days_late is not None:
235
- self.student_deductions.set_days_late(
236
- tuple(net_ids), pending_days_late
196
+ # Save submit_time now that grading succeeded
197
+ if pending_submit_time is not None:
198
+ self.student_deductions.set_submit_time(
199
+ tuple(net_ids), pending_submit_time
237
200
  )
238
201
  # Ensure student is in the deductions file
239
202
  self.student_deductions.ensure_student_in_file(tuple(net_ids))
@@ -270,10 +233,10 @@ class GradeItem:
270
233
  build = False
271
234
  continue
272
235
 
273
- # Record score - save days_late and ensure the student is in the deductions file
236
+ # Record score - save submit_time and ensure the student is in the deductions file
274
237
  # (even if they have no deductions, to indicate they were graded)
275
- if pending_days_late is not None:
276
- self.student_deductions.set_days_late(tuple(net_ids), pending_days_late)
238
+ if pending_submit_time is not None:
239
+ self.student_deductions.set_submit_time(tuple(net_ids), pending_submit_time)
277
240
  self.student_deductions.ensure_student_in_file(tuple(net_ids))
278
241
  break
279
242
 
@@ -19,7 +19,7 @@ class LearningSuiteColumn:
19
19
  self.csv_col_name = None
20
20
  self.other_data = {}
21
21
 
22
- # First make sure the YAML file exists in a directory of the same name
22
+ # Make sure the YAML file exists
23
23
  if yaml_path.suffix != ".yaml":
24
24
  raise LearningSuiteColumnParseError(
25
25
  "The item_yaml_path must point to a .yaml file."
@@ -28,10 +28,6 @@ class LearningSuiteColumn:
28
28
  raise LearningSuiteColumnParseError(
29
29
  f"The specified YAML file does not exist: {yaml_path}"
30
30
  )
31
- if yaml_path.parent.name != yaml_path.stem:
32
- raise LearningSuiteColumnParseError(
33
- f"The YAML file must be located in a directory of the same name. Currently {yaml_path} is in {yaml_path.parent}"
34
- )
35
31
 
36
32
  with yaml_path.open("r") as f:
37
33
  data = yaml.safe_load(f)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.0.0
3
+ Version: 2.2.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
@@ -1,218 +0,0 @@
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 LearningSuiteColumn
11
-
12
-
13
- def generate_feedback_zip(
14
- yaml_path: pathlib.Path,
15
- class_list_csv_path: pathlib.Path,
16
- subitem_feedback_paths: Dict[str, pathlib.Path],
17
- output_zip_path: pathlib.Path = None,
18
- ) -> pathlib.Path:
19
- """Generate a zip file containing feedback files for each student.
20
-
21
- Args:
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).
24
- subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
25
- output_zip_path: Path for the output zip file. If None, defaults to
26
- <yaml_dir>/feedback.zip.
27
-
28
- Returns:
29
- Path to the generated zip file.
30
- """
31
- yaml_path = pathlib.Path(yaml_path)
32
- ls_column = LearningSuiteColumn(yaml_path)
33
-
34
- # Get the lab name from the YAML file's parent directory
35
- lab_name = yaml_path.stem
36
-
37
- # Default output path
38
- if output_zip_path is None:
39
- output_zip_path = yaml_path.parent / "feedback.zip"
40
-
41
- # Load all student deductions for each subitem
42
- subitem_deductions: Dict[str, StudentDeductions] = {}
43
- for subitem in ls_column.items:
44
- if subitem.name in subitem_feedback_paths:
45
- feedback_path = subitem_feedback_paths[subitem.name]
46
- if feedback_path.exists():
47
- subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
48
- else:
49
- subitem_deductions[subitem.name] = StudentDeductions()
50
- else:
51
- subitem_deductions[subitem.name] = StudentDeductions()
52
-
53
- # Load class list from provided CSV
54
- students_df = pandas.read_csv(class_list_csv_path)
55
-
56
- # Create the zip file
57
- with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
58
- for _, student_row in students_df.iterrows():
59
- first_name = str(student_row["First Name"]).strip()
60
- last_name = str(student_row["Last Name"]).strip()
61
- net_id = str(student_row["Net ID"]).strip()
62
-
63
- # Generate feedback content for this student
64
- feedback_content = _generate_student_feedback(
65
- student_row=student_row,
66
- ls_column=ls_column,
67
- subitem_deductions=subitem_deductions,
68
- )
69
-
70
- # Generate filename
71
- filename = (
72
- first_name
73
- + "_"
74
- + last_name
75
- + "_"
76
- + net_id
77
- + "_feedback-"
78
- + lab_name
79
- + ".txt"
80
- )
81
-
82
- # Add to zip file
83
- zip_file.writestr(filename, feedback_content)
84
-
85
- return output_zip_path
86
-
87
-
88
- def _generate_student_feedback(
89
- student_row: pandas.Series,
90
- ls_column: LearningSuiteColumn,
91
- subitem_deductions: Dict[str, StudentDeductions],
92
- ) -> str:
93
- """Generate the feedback text content for a single student.
94
-
95
- Args:
96
- student_row: A row from the student DataFrame.
97
- ls_column: The LearningSuiteColumn object.
98
- subitem_deductions: Mapping from subitem name to StudentDeductions.
99
-
100
- Returns:
101
- The formatted feedback text.
102
- """
103
- net_id = str(student_row["Net ID"]).strip()
104
- first_name = str(student_row["First Name"]).strip()
105
- last_name = str(student_row["Last Name"]).strip()
106
-
107
- lines = []
108
- lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
109
- lines.append("=" * 60)
110
- lines.append("")
111
-
112
- total_points_possible = 0
113
- total_points_deducted = 0
114
-
115
- # Column widths for formatting
116
- feedback_col_width = 45
117
- points_col_width = 15
118
-
119
- for item in ls_column.items:
120
- subitem_points_possible = item.points
121
- total_points_possible += subitem_points_possible
122
-
123
- lines.append(f"{item.name} ({subitem_points_possible} points)")
124
- lines.append("-" * 60)
125
-
126
- # Header row
127
- header = f"{'Feedback':<{feedback_col_width}} {'Points Deducted':>{points_col_width}}"
128
- lines.append(header)
129
- lines.append("-" * 60)
130
-
131
- subitem_points_deducted = 0
132
-
133
- # Get deductions for this student in this item
134
- student_deductions_obj = subitem_deductions.get(item.name)
135
- if student_deductions_obj:
136
- # Find the student's deductions (try single net_id first, then tuple)
137
- student_key = None
138
- if (net_id,) in student_deductions_obj.deductions_by_students:
139
- student_key = (net_id,)
140
- else:
141
- # Check for multi-student keys containing this net_id
142
- for key in student_deductions_obj.deductions_by_students.keys():
143
- if net_id in key:
144
- student_key = key
145
- break
146
-
147
- if student_key:
148
- deductions = student_deductions_obj.deductions_by_students[student_key]
149
- for deduction in deductions:
150
- feedback_text = deduction.message
151
- points = deduction.points
152
-
153
- # Wrap long feedback text
154
- wrapped_lines = _wrap_text(feedback_text, feedback_col_width)
155
- for i, line_text in enumerate(wrapped_lines):
156
- if i == 0:
157
- row = f"{line_text:<{feedback_col_width}} {points:>{points_col_width}.1f}"
158
- else:
159
- row = f"{line_text:<{feedback_col_width}} {'':{points_col_width}}"
160
- lines.append(row)
161
-
162
- subitem_points_deducted += points
163
-
164
- if subitem_points_deducted == 0:
165
- lines.append(
166
- f"{'No deductions':<{feedback_col_width}} {'0.0':>{points_col_width}}"
167
- )
168
-
169
- lines.append("-" * 60)
170
- subitem_score = subitem_points_possible - subitem_points_deducted
171
- lines.append(
172
- f"{'Subitem Total:':<{feedback_col_width}} {subitem_score:>{points_col_width}.1f} / {subitem_points_possible:.1f}"
173
- )
174
- lines.append("")
175
-
176
- total_points_deducted += subitem_points_deducted
177
-
178
- # Total score section
179
- lines.append("=" * 60)
180
- total_score = total_points_possible - total_points_deducted
181
- lines.append(
182
- f"{'TOTAL SCORE:':<{feedback_col_width}} {total_score:>{points_col_width}.1f} / {total_points_possible:.1f}"
183
- )
184
- lines.append("=" * 60)
185
-
186
- return "\n".join(lines)
187
-
188
-
189
- def _wrap_text(text: str, width: int) -> list:
190
- """Wrap text to fit within a given width.
191
-
192
- Args:
193
- text: The text to wrap.
194
- width: Maximum width for each line.
195
-
196
- Returns:
197
- List of wrapped lines.
198
- """
199
- if len(text) <= width:
200
- return [text]
201
-
202
- words = text.split()
203
- lines = []
204
- current_line = ""
205
-
206
- for word in words:
207
- if not current_line:
208
- current_line = word
209
- elif len(current_line) + 1 + len(word) <= width:
210
- current_line += " " + word
211
- else:
212
- lines.append(current_line)
213
- current_line = word
214
-
215
- if current_line:
216
- lines.append(current_line)
217
-
218
- return lines if lines else [""]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes