ygrader 2.4.0__tar.gz → 2.5.1__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: 2.4.0
3
+ Version: 2.5.1
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.4.0",
7
+ version="2.5.1",
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",
@@ -5,7 +5,6 @@ import pathlib
5
5
  import zipfile
6
6
  from typing import Callable, Dict, Optional, Tuple
7
7
 
8
- import numpy as np
9
8
  import pandas
10
9
  import yaml
11
10
 
@@ -14,8 +13,12 @@ from .grading_item_config import LearningSuiteColumn
14
13
  from .utils import warning, print_color, TermColors
15
14
 
16
15
 
17
- # Type alias for late penalty callback: (late_days, max_score, actual_score) -> new_score
18
- LatePenaltyCallback = Callable[[int, float, float], float]
16
+ # Type alias for late penalty callback:
17
+ # (due_datetime, submitted_datetime, max_score, actual_score) -> new_score
18
+ # If submitted_datetime is None, the student submitted on time or no submit time was recorded.
19
+ LatePenaltyCallback = Callable[
20
+ [datetime.datetime, Optional[datetime.datetime], float, float], float
21
+ ]
19
22
 
20
23
 
21
24
  def _load_due_date_exceptions(
@@ -48,43 +51,13 @@ def _load_due_date_exceptions(
48
51
  return exceptions
49
52
 
50
53
 
51
- def _calculate_late_days(
52
- submit_time_str: Optional[str],
53
- due_date: datetime.datetime,
54
- ) -> int:
55
- """Calculate the number of business days late from a submit time.
56
-
57
- Args:
58
- submit_time_str: ISO format timestamp string of submission.
59
- due_date: The effective due date for the student.
60
-
61
- Returns:
62
- Number of business days late (0 if on time or no submit time).
63
- """
64
- if not submit_time_str:
65
- return 0
66
-
67
- try:
68
- submit_time = datetime.datetime.fromisoformat(submit_time_str)
69
- except ValueError:
70
- return 0
71
-
72
- if submit_time <= due_date:
73
- return 0
74
-
75
- days_late = np.busday_count(due_date.date(), submit_time.date())
76
- if days_late == 0:
77
- days_late = 1 # Same day but after deadline
78
- return int(days_late)
79
-
80
-
81
- def _get_student_key_and_max_late_days(
54
+ def _get_student_key_and_submit_info(
82
55
  net_id: str,
83
56
  item_deductions: Dict[str, StudentDeductions],
84
57
  due_date: Optional[datetime.datetime] = None,
85
58
  due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
86
- ) -> tuple:
87
- """Find the student key and maximum late days across all items.
59
+ ) -> Tuple[Optional[tuple], Optional[datetime.datetime], Optional[datetime.datetime]]:
60
+ """Find the student key and submission timing info across all items.
88
61
 
89
62
  Args:
90
63
  net_id: The student's net ID.
@@ -93,10 +66,12 @@ def _get_student_key_and_max_late_days(
93
66
  due_date_exceptions: Mapping from net_id to exception due date.
94
67
 
95
68
  Returns:
96
- Tuple of (student_key or None, max_late_days).
69
+ Tuple of (student_key or None, effective_due_date or None, latest_submit_time or None).
70
+ latest_submit_time is None if on time or no submit time recorded.
97
71
  """
98
- max_late_days = 0
99
72
  found_student_key = None
73
+ latest_submit_time: Optional[datetime.datetime] = None
74
+ effective_due_date: Optional[datetime.datetime] = due_date
100
75
 
101
76
  if due_date_exceptions is None:
102
77
  due_date_exceptions = {}
@@ -123,26 +98,35 @@ def _get_student_key_and_max_late_days(
123
98
  if student_key:
124
99
  found_student_key = student_key
125
100
 
126
- # Calculate late days from submit_time if we have a due date
101
+ # Calculate effective due date (using most generous exception for group)
127
102
  if due_date is not None:
128
- submit_time_str = deductions_obj.submit_time_by_students.get(
129
- student_key
130
- )
131
- if submit_time_str:
132
- # Calculate effective due date (using most generous exception for group)
133
- effective_due_date = due_date
134
- for member_net_id in student_key:
135
- if member_net_id in due_date_exceptions:
136
- effective_due_date = max(
137
- effective_due_date, due_date_exceptions[member_net_id]
138
- )
139
-
140
- days_late = _calculate_late_days(
141
- submit_time_str, effective_due_date
142
- )
143
- max_late_days = max(max_late_days, days_late)
144
-
145
- return found_student_key, max_late_days
103
+ effective_due_date = due_date
104
+ for member_net_id in student_key:
105
+ if member_net_id in due_date_exceptions:
106
+ effective_due_date = max(
107
+ effective_due_date, due_date_exceptions[member_net_id]
108
+ )
109
+
110
+ # Get submit time
111
+ submit_time_str = deductions_obj.submit_time_by_students.get(student_key)
112
+ if submit_time_str:
113
+ try:
114
+ submit_time = datetime.datetime.fromisoformat(submit_time_str)
115
+ # Track latest submit time across all items
116
+ if latest_submit_time is None or submit_time > latest_submit_time:
117
+ latest_submit_time = submit_time
118
+ except ValueError:
119
+ pass
120
+
121
+ # Return None for submit_time if on time
122
+ if (
123
+ latest_submit_time is not None
124
+ and effective_due_date is not None
125
+ and latest_submit_time <= effective_due_date
126
+ ):
127
+ latest_submit_time = None
128
+
129
+ return found_student_key, effective_due_date, latest_submit_time
146
130
 
147
131
 
148
132
  def _calculate_student_score(
@@ -154,7 +138,7 @@ def _calculate_student_score(
154
138
  warn_on_missing_callback: bool = True,
155
139
  due_date: Optional[datetime.datetime] = None,
156
140
  due_date_exceptions: Optional[Dict[str, datetime.datetime]] = None,
157
- ) -> Tuple[float, float, int]:
141
+ ) -> Tuple[float, float, Optional[datetime.datetime]]:
158
142
  """Calculate a student's final score.
159
143
 
160
144
  Args:
@@ -162,18 +146,21 @@ def _calculate_student_score(
162
146
  ls_column: The LearningSuiteColumn configuration.
163
147
  item_deductions: Mapping from item name to StudentDeductions.
164
148
  late_penalty_callback: Optional callback for late penalty.
165
- warn_on_missing_callback: Whether to warn if late days found but no callback.
149
+ warn_on_missing_callback: Whether to warn if late but no callback.
166
150
  due_date: The default due date for the assignment.
167
151
  due_date_exceptions: Mapping from net_id to exception due date.
168
152
 
169
153
  Returns:
170
- Tuple of (final_score, total_possible, max_late_days).
154
+ Tuple of (final_score, total_possible, submitted_datetime or None if on time).
171
155
  """
172
156
  total_possible = sum(item.points for item in ls_column.items)
173
- total_deductions = 0.0
157
+ total_score = 0.0
174
158
 
175
159
  for item in ls_column.items:
176
160
  deductions_obj = item_deductions.get(item.name)
161
+ student_graded = False
162
+ item_deduction_total = 0.0
163
+
177
164
  if deductions_obj:
178
165
  # Find the student's deductions
179
166
  student_key = None
@@ -186,28 +173,39 @@ def _calculate_student_score(
186
173
  break
187
174
 
188
175
  if student_key:
176
+ student_graded = True
189
177
  deductions = deductions_obj.deductions_by_students[student_key]
190
178
  for deduction in deductions:
191
- total_deductions += deduction.points
179
+ item_deduction_total += deduction.points
192
180
 
193
- # Calculate score before late penalty
194
- score = max(0, total_possible - total_deductions)
181
+ # Only award points if the student was graded for this item
182
+ if student_graded:
183
+ total_score += max(0, item.points - item_deduction_total)
184
+ # else: student gets 0 for this item (not graded)
195
185
 
196
- # Get max late days
197
- _, max_late_days = _get_student_key_and_max_late_days(
186
+ # Score is already calculated
187
+ score = total_score
188
+
189
+ # Get submit info
190
+ _, effective_due_date, submitted_datetime = _get_student_key_and_submit_info(
198
191
  net_id, item_deductions, due_date, due_date_exceptions
199
192
  )
200
193
 
201
- # Apply late penalty if applicable
202
- if max_late_days > 0:
203
- if late_penalty_callback:
204
- score = max(0, late_penalty_callback(max_late_days, total_possible, score))
194
+ # Apply late penalty if applicable (submitted_datetime is None if on time)
195
+ if submitted_datetime is not None:
196
+ if late_penalty_callback and effective_due_date is not None:
197
+ score = max(
198
+ 0,
199
+ late_penalty_callback(
200
+ effective_due_date, submitted_datetime, total_possible, score
201
+ ),
202
+ )
205
203
  elif warn_on_missing_callback:
206
204
  warning(
207
- f"Student {net_id} has {max_late_days} late day(s) but no late penalty callback provided"
205
+ f"Student {net_id} submitted late but no late penalty callback provided"
208
206
  )
209
207
 
210
- return score, total_possible, max_late_days
208
+ return score, total_possible, submitted_datetime
211
209
 
212
210
 
213
211
  def assemble_grades(
@@ -230,7 +228,8 @@ def assemble_grades(
230
228
  output_zip_path: Path for the output zip file. If None, no zip is generated.
231
229
  output_csv_path: Path for the output CSV file. If None, no CSV is generated.
232
230
  late_penalty_callback: Optional callback function that takes
233
- (late_days, max_score, actual_score) and returns the adjusted score.
231
+ (due_datetime, submitted_datetime, max_score, actual_score) and returns the adjusted score.
232
+ submitted_datetime will be None if on time.
234
233
  due_date: The default due date for the assignment. Required for late penalty.
235
234
  due_date_exceptions_path: Path to YAML file with due date exceptions (net_id: "YYYY-MM-DD HH:MM:SS").
236
235
 
@@ -299,8 +298,15 @@ def assemble_grades(
299
298
  f"but NOT [{', '.join(items_not_graded)}]",
300
299
  )
301
300
 
301
+ # Get submit info for this student
302
+ _, effective_due_date, submitted_datetime = (
303
+ _get_student_key_and_submit_info(
304
+ net_id, subitem_deductions, due_date, due_date_exceptions
305
+ )
306
+ )
307
+
302
308
  # Calculate score before late penalty
303
- score_before_late, total_possible, max_late_days = _calculate_student_score(
309
+ score_before_late, total_possible, _ = _calculate_student_score(
304
310
  net_id=net_id,
305
311
  ls_column=ls_column,
306
312
  item_deductions=subitem_deductions,
@@ -310,18 +316,25 @@ def assemble_grades(
310
316
  due_date_exceptions=due_date_exceptions,
311
317
  )
312
318
 
313
- # Apply late penalty if applicable
319
+ # Apply late penalty if applicable (submitted_datetime is None if on time)
314
320
  final_score = score_before_late
315
- if max_late_days > 0 and late_penalty_callback:
321
+ if (
322
+ submitted_datetime is not None
323
+ and late_penalty_callback
324
+ and effective_due_date is not None
325
+ ):
316
326
  final_score = max(
317
327
  0,
318
328
  late_penalty_callback(
319
- max_late_days, total_possible, score_before_late
329
+ effective_due_date,
330
+ submitted_datetime,
331
+ total_possible,
332
+ score_before_late,
320
333
  ),
321
334
  )
322
335
  print_color(
323
336
  TermColors.YELLOW,
324
- f"Late: {net_id} ({max_late_days} day{'s' if max_late_days != 1 else ''}): "
337
+ f"Late: {net_id} (submitted {submitted_datetime}): "
325
338
  f"{score_before_late:.1f} -> {final_score:.1f}",
326
339
  )
327
340
 
@@ -419,7 +432,8 @@ def _generate_student_feedback(
419
432
  total_points_possible += subitem_points_possible
420
433
 
421
434
  subitem_points_deducted = 0
422
- item_deductions = []
435
+ item_deduction_list = []
436
+ student_graded = False
423
437
 
424
438
  # Get deductions for this student in this item
425
439
  student_deductions_obj = subitem_deductions.get(item.name)
@@ -436,13 +450,19 @@ def _generate_student_feedback(
436
450
  break
437
451
 
438
452
  if student_key:
453
+ student_graded = True
439
454
  deductions = student_deductions_obj.deductions_by_students[student_key]
440
455
  for deduction in deductions:
441
- item_deductions.append((deduction.message, deduction.points))
456
+ item_deduction_list.append((deduction.message, deduction.points))
442
457
  subitem_points_deducted += deduction.points
443
458
 
444
- # Calculate item score
445
- subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
459
+ # Calculate item score (0 if not graded)
460
+ if student_graded:
461
+ subitem_score = max(0, subitem_points_possible - subitem_points_deducted)
462
+ else:
463
+ subitem_score = 0
464
+ item_deduction_list.append(("Not graded", subitem_points_possible))
465
+ subitem_points_deducted = subitem_points_possible
446
466
  score_str = f"{subitem_score:.1f} / {subitem_points_possible:.1f}"
447
467
 
448
468
  # Item line with score
@@ -452,7 +472,7 @@ def _generate_student_feedback(
452
472
  )
453
473
 
454
474
  # Deduction lines (indented)
455
- for msg, pts in item_deductions:
475
+ for msg, pts in item_deduction_list:
456
476
  # Wrap long messages
457
477
  wrapped = _wrap_text(msg, deduction_msg_width)
458
478
  for i, line_text in enumerate(wrapped):
@@ -469,23 +489,30 @@ def _generate_student_feedback(
469
489
  # Calculate score before late penalty (clamped to 0)
470
490
  score_before_late = max(0, total_points_possible - total_points_deducted)
471
491
 
472
- # Get max late days for this student
473
- _, max_late_days = _get_student_key_and_max_late_days(
492
+ # Get submit info for this student
493
+ _, effective_due_date, submitted_datetime = _get_student_key_and_submit_info(
474
494
  net_id, subitem_deductions, due_date, due_date_exceptions
475
495
  )
476
496
 
477
497
  # Late penalty section
478
498
  lines.append("")
479
499
  lines.append("=" * 60)
480
- if max_late_days > 0 and late_penalty_callback:
500
+ if (
501
+ submitted_datetime is not None
502
+ and late_penalty_callback
503
+ and effective_due_date is not None
504
+ ):
481
505
  final_score = late_penalty_callback(
482
- max_late_days, total_points_possible, score_before_late
506
+ effective_due_date,
507
+ submitted_datetime,
508
+ total_points_possible,
509
+ score_before_late,
483
510
  )
484
511
  # Ensure final score is not negative
485
512
  final_score = max(0, final_score)
486
513
  late_penalty_points = score_before_late - final_score
487
514
  late_label = (
488
- f"Late Penalty ({max_late_days} day{'s' if max_late_days != 1 else ''}):"
515
+ f"Late Penalty (submitted {submitted_datetime.strftime('%Y-%m-%d %H:%M')}):"
489
516
  )
490
517
  lines.append(
491
518
  f"{late_label:<{item_col_width}} {-late_penalty_points:>{score_col_width}.1f}"
@@ -37,12 +37,15 @@ class GradeItem:
37
37
  self.fcn_args_dict = fcn_args_dict if fcn_args_dict is not None else {}
38
38
  self.student_deductions = StudentDeductions(deductions_yaml_path)
39
39
  self.last_graded_net_ids = None # Track last graded student for undo
40
- self.names_by_netid = self._build_names_lookup() # net_id -> (first_name, last_name)
40
+ self.names_by_netid = (
41
+ self._build_names_lookup()
42
+ ) # net_id -> (first_name, last_name)
41
43
 
42
44
  def _build_names_lookup(self):
43
45
  """Build a lookup dictionary from net_id to (first_name, last_name) from the class list CSV."""
44
46
  # Import pandas here to avoid circular import and since it's already imported in grader.py
45
47
  import pandas # pylint: disable=import-outside-toplevel
48
+
46
49
  names_by_netid = {}
47
50
  try:
48
51
  df = pandas.read_csv(self.grader.class_list_csv_path)
@@ -51,7 +54,11 @@ class GradeItem:
51
54
  net_id = row["Net ID"]
52
55
  first_name = row["First Name"]
53
56
  last_name = row["Last Name"]
54
- if pandas.notna(net_id) and pandas.notna(first_name) and pandas.notna(last_name):
57
+ if (
58
+ pandas.notna(net_id)
59
+ and pandas.notna(first_name)
60
+ and pandas.notna(last_name)
61
+ ):
55
62
  names_by_netid[net_id] = (first_name, last_name)
56
63
  except (FileNotFoundError, pandas.errors.EmptyDataError, KeyError):
57
64
  pass # If we can't read the CSV, just use an empty dict
@@ -259,6 +266,9 @@ class GradeItem:
259
266
  # run again, but don't build
260
267
  build = False
261
268
  continue
269
+ if score == ScoreResult.EXIT:
270
+ print_color(TermColors.BLUE, "Exiting grader")
271
+ sys.exit(0)
262
272
  if score == ScoreResult.UNDO_LAST:
263
273
  # Undo the last graded student and signal to go back
264
274
  if self.last_graded_net_ids is not None:
@@ -276,7 +286,9 @@ class GradeItem:
276
286
  # Record score - save submit_time and ensure the student is in the deductions file
277
287
  # (even if they have no deductions, to indicate they were graded)
278
288
  if pending_submit_time is not None:
279
- self.student_deductions.set_submit_time(tuple(net_ids), pending_submit_time)
289
+ self.student_deductions.set_submit_time(
290
+ tuple(net_ids), pending_submit_time
291
+ )
280
292
  self.student_deductions.ensure_student_in_file(tuple(net_ids))
281
293
  # Track this student as last graded for undo functionality
282
294
  self.last_graded_net_ids = tuple(net_ids)
@@ -13,6 +13,7 @@ class ScoreResult(Enum):
13
13
  RERUN = auto()
14
14
  CREATE_DEDUCTION = auto()
15
15
  UNDO_LAST = auto()
16
+ EXIT = auto()
16
17
 
17
18
 
18
19
  def get_score(
@@ -55,8 +56,7 @@ def get_score(
55
56
  # Show current deductions for this student
56
57
  current_deductions = student_deductions.get_student_deductions(tuple(net_ids))
57
58
  print(
58
- fpad2
59
- + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
59
+ fpad2 + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
60
60
  )
61
61
  print(fpad2 + "Current deductions:")
62
62
  if current_deductions:
@@ -99,11 +99,15 @@ def get_score(
99
99
  left_items.append(("[g]", "Manage grades"))
100
100
  allowed_cmds["g"] = "manage"
101
101
 
102
- # Add undo option last if there's a last graded student
102
+ # Add undo option if there's a last graded student
103
103
  if last_graded_net_ids is not None:
104
104
  left_items.append(("[u]", f"Undo last ({last_graded_net_ids[0]})"))
105
105
  allowed_cmds["u"] = ScoreResult.UNDO_LAST
106
106
 
107
+ # Add exit option at bottom of left column
108
+ left_items.append(("[e]", "Exit grader"))
109
+ allowed_cmds["e"] = ScoreResult.EXIT
110
+
107
111
  # Format menu items in two columns
108
112
  col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
109
113
  input_txt = (
@@ -269,7 +273,10 @@ def _manage_grades_interactive(student_deductions, names_by_netid=None):
269
273
  # Check if search matches first/last name
270
274
  if names_by_netid and net_id in names_by_netid:
271
275
  first_name, last_name = names_by_netid[net_id]
272
- if not list_all and (search_lower in first_name.lower() or search_lower in last_name.lower()):
276
+ if not list_all and (
277
+ search_lower in first_name.lower()
278
+ or search_lower in last_name.lower()
279
+ ):
273
280
  match_found = True
274
281
  display_parts.append(f"{first_name} {last_name} ({net_id})")
275
282
  else:
@@ -309,9 +316,11 @@ def _manage_grades_interactive(student_deductions, names_by_netid=None):
309
316
  if 0 <= idx < len(matches):
310
317
  student_key, display = matches[idx]
311
318
  # Confirm deletion
312
- confirm = input(
313
- f"Delete grade for {display}? This cannot be undone. [y/N]: "
314
- ).strip().lower()
319
+ confirm = (
320
+ input(f"Delete grade for {display}? This cannot be undone. [y/N]: ")
321
+ .strip()
322
+ .lower()
323
+ )
315
324
 
316
325
  if confirm == "y":
317
326
  student_deductions.clear_student_deductions(student_key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.4.0
3
+ Version: 2.5.1
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes