ygrader 2.6.6__tar.gz → 2.6.10__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 (25) hide show
  1. {ygrader-2.6.6/ygrader.egg-info → ygrader-2.6.10}/PKG-INFO +1 -1
  2. {ygrader-2.6.6 → ygrader-2.6.10}/setup.py +1 -1
  3. {ygrader-2.6.6 → ygrader-2.6.10}/test/test_unittest.py +3 -0
  4. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grader.py +36 -4
  5. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grading_item.py +29 -24
  6. ygrader-2.6.10/ygrader/score_input.py +517 -0
  7. {ygrader-2.6.6 → ygrader-2.6.10/ygrader.egg-info}/PKG-INFO +1 -1
  8. ygrader-2.6.6/ygrader/score_input.py +0 -343
  9. {ygrader-2.6.6 → ygrader-2.6.10}/LICENSE +0 -0
  10. {ygrader-2.6.6 → ygrader-2.6.10}/setup.cfg +0 -0
  11. {ygrader-2.6.6 → ygrader-2.6.10}/test/test_interactive.py +0 -0
  12. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/__init__.py +0 -0
  13. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/deductions.py +0 -0
  14. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/feedback.py +0 -0
  15. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grades_csv.py +0 -0
  16. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/grading_item_config.py +0 -0
  17. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/remote.py +0 -0
  18. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/send_ctrl_backtick.ahk +0 -0
  19. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/student_repos.py +0 -0
  20. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/upstream_merger.py +0 -0
  21. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader/utils.py +0 -0
  22. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader.egg-info/SOURCES.txt +0 -0
  23. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader.egg-info/dependency_links.txt +0 -0
  24. {ygrader-2.6.6 → ygrader-2.6.10}/ygrader.egg-info/requires.txt +0 -0
  25. {ygrader-2.6.6 → ygrader-2.6.10}/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.6.6
3
+ Version: 2.6.10
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.6.6",
7
+ version="2.6.10",
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",
@@ -50,6 +50,7 @@ class TestGithub(unittest.TestCase):
50
50
  grader.set_submission_system_github(
51
51
  "main", TEST_RESOURCES_PATH / "github.csv", use_https=True
52
52
  )
53
+ grader.set_other_options(show_completion_menu=False)
53
54
  grader.run()
54
55
 
55
56
  self.assertTrue(filecmp.cmp(deductions1_yaml_path, deductions1_golden_path))
@@ -83,6 +84,7 @@ class TestLearningSuite(unittest.TestCase):
83
84
  grader.set_submission_system_learning_suite(
84
85
  TEST_RESOURCES_PATH / "submissions.zip"
85
86
  )
87
+ grader.set_other_options(show_completion_menu=False)
86
88
  grader.run()
87
89
 
88
90
  self.assertTrue(filecmp.cmp(deductions_path, deductions_golden_path))
@@ -127,6 +129,7 @@ class TestLearningSuite(unittest.TestCase):
127
129
  TEST_RESOURCES_PATH / "submissions2.zip"
128
130
  )
129
131
  grader.set_learning_suite_groups(TEST_RESOURCES_PATH / "groups3.csv")
132
+ grader.set_other_options(show_completion_menu=False)
130
133
  grader.run()
131
134
 
132
135
  self.assertTrue(filecmp.cmp(deductions1_path, deductions1_golden_path))
@@ -21,6 +21,7 @@ import pandas
21
21
 
22
22
  from . import grades_csv, student_repos, utils
23
23
  from .grading_item import GradeItem
24
+ from .score_input import display_completion_menu
24
25
  from .utils import (
25
26
  CallbackFailed,
26
27
  TermColors,
@@ -98,6 +99,8 @@ class Grader:
98
99
  self.items = []
99
100
  self.code_source = None
100
101
  self.prep_fcn = None
102
+ self.last_graded_net_ids = None # Track last fully graded student for undo
103
+ self.show_completion_menu = True # Show menu when grading completes
101
104
  self.learning_suite_submissions_zip_path = None
102
105
  self.github_csv_path = None
103
106
  self.github_csv_col_name = None
@@ -342,6 +345,7 @@ class Grader:
342
345
  dry_run_all=False,
343
346
  workflow_hash=None,
344
347
  parallel_workers=None,
348
+ show_completion_menu=True,
345
349
  ):
346
350
  """
347
351
  This can be used to set other options for the grader.
@@ -387,6 +391,9 @@ class Grader:
387
391
  If set to an integer, process students in parallel using that many workers.
388
392
  Only works with build_only mode since interactive grading cannot be parallelized.
389
393
  Default is None (sequential processing).
394
+ show_completion_menu: bool
395
+ Whether to show the completion menu when all students are graded.
396
+ Default is True.
390
397
  """
391
398
  self.format_code = format_code
392
399
  self.build_only = build_only
@@ -416,6 +423,8 @@ class Grader:
416
423
  if parallel_workers is not None and not build_only:
417
424
  error("parallel_workers is only supported when build_only=True")
418
425
 
426
+ self.show_completion_menu = show_completion_menu
427
+
419
428
  def _validate_config(self):
420
429
  """Check that everything has been configured before running"""
421
430
  # Check that callback function has been set up
@@ -495,10 +504,10 @@ class Grader:
495
504
  self._run_grading_sequential(student_grades_df, grouped_df)
496
505
 
497
506
  def _run_grading_sequential(self, student_grades_df, grouped_df):
498
- # Sort by last name for consistent grading order
499
- # After groupby().agg(list), "Last Name" is a list, so we sort by the first element
507
+ # Sort by first name for consistent grading order (matches display name sorting in [g] menu)
508
+ # After groupby().agg(list), "First Name" is a list, so we sort by the first element
500
509
  sorted_df = grouped_df.sort_values(
501
- by="Last Name", key=lambda x: x.apply(lambda names: names[0].lower())
510
+ by="First Name", key=lambda x: x.apply(lambda names: names[0].lower())
502
511
  )
503
512
 
504
513
  # Convert to list for index-based iteration (needed for going back)
@@ -588,8 +597,18 @@ class Grader:
588
597
  # Go back to previous student
589
598
  idx = prev_idx
590
599
  prev_idx = None # Clear so we can't go back twice in a row
600
+ self.last_graded_net_ids = None # Clear since we're undoing
591
601
  continue
592
602
 
603
+ # Track this student as last graded (only if all items completed normally)
604
+ if (
605
+ not go_back
606
+ and not self.build_only
607
+ and not self.dry_run_first
608
+ and not self.dry_run_all
609
+ ):
610
+ self.last_graded_net_ids = tuple(net_ids)
611
+
593
612
  if self.dry_run_first:
594
613
  print_color(
595
614
  TermColors.YELLOW,
@@ -601,6 +620,17 @@ class Grader:
601
620
  prev_idx = idx
602
621
  idx += 1
603
622
 
623
+ # Show completion menu when all students are done (unless disabled or in special modes)
624
+ if (
625
+ self.show_completion_menu
626
+ and not self.build_only
627
+ and not self.dry_run_first
628
+ and not self.dry_run_all
629
+ ):
630
+ # Get names_by_netid from first item for display purposes
631
+ names_by_netid = self.items[0].names_by_netid if self.items else None
632
+ display_completion_menu(self.items, names_by_netid)
633
+
604
634
  def _process_single_student_build(self, row):
605
635
  """Process a single student for parallel build mode. Returns (net_ids, success, message, log_path)."""
606
636
  first_names = grades_csv.get_first_names(row)
@@ -896,7 +926,9 @@ class Grader:
896
926
  student_work_path.mkdir(parents=True, exist_ok=True)
897
927
 
898
928
  # Clone student repo
899
- https_url = student_repos.convert_github_url_format(row["github_url"], to_https=True)
929
+ https_url = student_repos.convert_github_url_format(
930
+ row["github_url"], to_https=True
931
+ )
900
932
  print("Student repo url: " + https_url, file=output)
901
933
  if not student_repos.clone_repo(
902
934
  row["github_url"], self.github_tag, student_work_path, output=output
@@ -12,7 +12,7 @@ from .utils import (
12
12
  )
13
13
  from . import grades_csv
14
14
  from .deductions import StudentDeductions
15
- from .score_input import get_score, ScoreResult
15
+ from .score_input import get_score, MenuCommand
16
16
 
17
17
 
18
18
  class GradeItem:
@@ -36,7 +36,6 @@ class GradeItem:
36
36
  self.max_points = max_points
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
- self.last_graded_net_ids = None # Track last graded student for undo
40
39
  self.names_by_netid = (
41
40
  self._build_names_lookup()
42
41
  ) # net_id -> (first_name, last_name)
@@ -151,9 +150,7 @@ class GradeItem:
151
150
  student_code_path / ".github" / "workflows" / "submission.yml"
152
151
  )
153
152
  try:
154
- verify_workflow_hash(
155
- workflow_file_path, self.grader.workflow_hash
156
- )
153
+ verify_workflow_hash(workflow_file_path, self.grader.workflow_hash)
157
154
  except WorkflowHashError as e:
158
155
  workflow_errors.append(f"Workflow hash mismatch: {e}")
159
156
 
@@ -196,9 +193,7 @@ class GradeItem:
196
193
  TermColors.RED,
197
194
  "This student may have modified the GitHub workflow system.",
198
195
  )
199
- print_color(
200
- TermColors.RED, "The submission date CANNOT be guaranteed."
201
- )
196
+ print_color(TermColors.RED, "The submission date CANNOT be guaranteed.")
202
197
  print_color(TermColors.RED, "")
203
198
  print_color(
204
199
  TermColors.RED,
@@ -209,9 +204,11 @@ class GradeItem:
209
204
 
210
205
  # Ask for confirmation before grading
211
206
  while True:
212
- response = input(
213
- "Do you want to grade this student anyway? [y/n]: "
214
- ).strip().lower()
207
+ response = (
208
+ input("Do you want to grade this student anyway? [y/n]: ")
209
+ .strip()
210
+ .lower()
211
+ )
215
212
  if response in ("y", "yes"):
216
213
  break
217
214
  if response in ("n", "no"):
@@ -271,35 +268,45 @@ class GradeItem:
271
268
  allow_rerun=self.grader.allow_rerun,
272
269
  student_deductions=self.student_deductions,
273
270
  net_ids=tuple(net_ids),
274
- last_graded_net_ids=self.last_graded_net_ids,
271
+ last_graded_net_ids=self.grader.last_graded_net_ids,
275
272
  names_by_netid=self.names_by_netid,
273
+ all_items=self.grader.items,
276
274
  )
277
275
  except KeyboardInterrupt:
278
276
  print_color(TermColors.RED, "\nExiting")
279
277
  sys.exit(0)
280
278
 
281
- if score == ScoreResult.SKIP:
279
+ if score == MenuCommand.SKIP:
282
280
  return False
283
- if score == ScoreResult.REBUILD:
281
+ if score == MenuCommand.BUILD:
284
282
  continue
285
- if score == ScoreResult.RERUN:
283
+ if score == MenuCommand.RERUN:
286
284
  # run again, but don't build
287
285
  build = False
288
286
  continue
289
- if score == ScoreResult.EXIT:
287
+ if score == MenuCommand.EXIT:
290
288
  print_color(TermColors.BLUE, "Exiting grader")
291
289
  sys.exit(0)
292
- if score == ScoreResult.UNDO_LAST:
290
+ if score == MenuCommand.UNDO:
293
291
  # Undo the last graded student and signal to go back
294
- if self.last_graded_net_ids is not None:
295
- self.student_deductions.clear_student_deductions(
296
- self.last_graded_net_ids
292
+ if self.grader.last_graded_net_ids is not None:
293
+ # Clear deductions for ALL items for the last graded student
294
+ for item in self.grader.items:
295
+ item.student_deductions.clear_student_deductions(
296
+ self.grader.last_graded_net_ids
297
+ )
298
+ # Also clear any partial grades for the CURRENT student
299
+ # so they start fresh when we come back to them
300
+ for item in self.grader.items:
301
+ item.student_deductions.clear_student_deductions(tuple(net_ids))
302
+ print_color(
303
+ TermColors.GREEN,
304
+ f"Undid grade for {', '.join(self.grader.last_graded_net_ids)} - going back to regrade",
297
305
  )
298
306
  print_color(
299
307
  TermColors.GREEN,
300
- f"Undid grade for {', '.join(self.last_graded_net_ids)} - going back to regrade",
308
+ f"Also cleared partial grades for {', '.join(net_ids)}",
301
309
  )
302
- self.last_graded_net_ids = None
303
310
  return True # Signal to go back to previous student
304
311
  continue
305
312
 
@@ -310,8 +317,6 @@ class GradeItem:
310
317
  tuple(net_ids), pending_submit_time
311
318
  )
312
319
  self.student_deductions.ensure_student_in_file(tuple(net_ids))
313
- # Track this student as last graded for undo functionality
314
- self.last_graded_net_ids = tuple(net_ids)
315
320
  return False # Normal completion
316
321
 
317
322
  # If we got here via break (CallbackFailed, build_only, dry_run, etc.)
@@ -0,0 +1,517 @@
1
+ """Module for prompting users for scores during grading."""
2
+
3
+ import sys
4
+ from enum import Enum
5
+
6
+ from .utils import TermColors, print_color
7
+
8
+
9
+ class MenuCommand(Enum):
10
+ """Enum representing menu commands, with character as value.
11
+
12
+ Used both for defining menu keys and as return values from get_score().
13
+ """
14
+
15
+ SKIP = "s"
16
+ BUILD = "b"
17
+ RERUN = "r"
18
+ NEW_DEDUCTION = "n"
19
+ DELETE_DEDUCTION = "d"
20
+ CLEAR_DEDUCTIONS = "0"
21
+ CHANGE_VALUE = "v"
22
+ MANAGE_GRADES = "g"
23
+ UNDO = "u"
24
+ EXIT = "e"
25
+
26
+ @property
27
+ def key_display(self):
28
+ """Return the key display string like '[s]'."""
29
+ return f"[{self.value}]"
30
+
31
+
32
+ def _print_menu_item(cmd, description, indent=" ", pad=10):
33
+ """Print a single menu item with consistent formatting."""
34
+ print(
35
+ indent
36
+ + TermColors.BLUE
37
+ + cmd.key_display.ljust(pad)
38
+ + TermColors.END
39
+ + description
40
+ )
41
+
42
+
43
+ def display_completion_menu(items, names_by_netid=None):
44
+ """Show a menu when all students have been graded.
45
+
46
+ Allows the user to manage grades, edit deduction types, etc.
47
+
48
+ Args:
49
+ items: List of GradeItem objects (to access their student_deductions)
50
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
51
+
52
+ Returns:
53
+ True if user wants to exit, False to continue showing the menu
54
+ """
55
+ if not items:
56
+ print_color(TermColors.YELLOW, "No grade items configured.")
57
+ return True
58
+
59
+ fpad2 = " " * 4
60
+
61
+ while True:
62
+ # Count total graded students across all items
63
+ all_graded_students = set()
64
+ for item in items:
65
+ for student_key in item.student_deductions.get_graded_students():
66
+ all_graded_students.add(student_key)
67
+
68
+ print("")
69
+ print("=" * 60)
70
+ print_color(TermColors.GREEN, "All students have been graded!")
71
+ print("=" * 60)
72
+ print("")
73
+
74
+ # Show summary
75
+ print(fpad2 + f"Total students graded: {len(all_graded_students)}")
76
+ print("")
77
+
78
+ # Show menu
79
+ menu_items = [
80
+ (MenuCommand.MANAGE_GRADES, "Manage grades (view/delete student grades)"),
81
+ (MenuCommand.DELETE_DEDUCTION, "Delete deduction type"),
82
+ (MenuCommand.CHANGE_VALUE, "Change deduction value"),
83
+ (MenuCommand.EXIT, "Exit grader"),
84
+ ]
85
+ print(fpad2 + "Options:")
86
+ for cmd, desc in menu_items:
87
+ _print_menu_item(cmd, desc, fpad2)
88
+ print("")
89
+
90
+ txt = input(TermColors.BLUE + ">>> " + TermColors.END).strip().lower()
91
+
92
+ if txt == MenuCommand.MANAGE_GRADES.value:
93
+ _manage_grades_interactive(items, names_by_netid)
94
+ elif txt == MenuCommand.DELETE_DEDUCTION.value:
95
+ selected_item = _select_item_interactive(
96
+ items, "delete deduction type from"
97
+ )
98
+ if selected_item:
99
+ selected_item.student_deductions.delete_deduction_type_interactive()
100
+ elif txt == MenuCommand.CHANGE_VALUE.value:
101
+ selected_item = _select_item_interactive(
102
+ items, "change deduction value for"
103
+ )
104
+ if selected_item:
105
+ selected_item.student_deductions.change_deduction_value_interactive(
106
+ max_points=selected_item.max_points
107
+ )
108
+ elif txt == MenuCommand.EXIT.value:
109
+ print_color(TermColors.BLUE, "Exiting grader")
110
+ sys.exit(0)
111
+ else:
112
+ print_color(TermColors.YELLOW, "Invalid option. Try again.")
113
+
114
+
115
+ def _select_item_interactive(items, action_description):
116
+ """Prompt user to select an item when there are multiple items.
117
+
118
+ Args:
119
+ items: List of GradeItem objects
120
+ action_description: String describing the action (e.g., "delete deduction type from")
121
+
122
+ Returns:
123
+ Selected GradeItem, or None if cancelled
124
+ """
125
+ if len(items) == 1:
126
+ return items[0]
127
+
128
+ print(f"\nSelect which item to {action_description}:")
129
+ for i, item in enumerate(items, 1):
130
+ print(f" [{i}] {item.item_name}")
131
+ print(" [Enter] Cancel")
132
+
133
+ txt = input("\n>>> ").strip()
134
+ if txt == "":
135
+ return None
136
+
137
+ try:
138
+ idx = int(txt) - 1
139
+ if 0 <= idx < len(items):
140
+ return items[idx]
141
+ print_color(TermColors.YELLOW, "Invalid selection.")
142
+ return None
143
+ except ValueError:
144
+ print_color(TermColors.YELLOW, "Invalid input.")
145
+ return None
146
+
147
+
148
+ def get_score(
149
+ names,
150
+ max_points,
151
+ *,
152
+ allow_rebuild=True,
153
+ allow_rerun=True,
154
+ student_deductions,
155
+ net_ids,
156
+ last_graded_net_ids=None,
157
+ names_by_netid=None,
158
+ all_items=None,
159
+ ):
160
+ """Prompts the user for a score for the grade column.
161
+
162
+ Args:
163
+ names: Student name(s) to display
164
+ max_points: Maximum points for this item
165
+ allow_rebuild: Whether rebuild option is available
166
+ allow_rerun: Whether rerun option is available
167
+ student_deductions: StudentDeductions object
168
+ net_ids: Tuple of net_ids for the current student
169
+ last_graded_net_ids: Tuple of net_ids for the last graded student (for undo)
170
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
171
+ all_items: List of all GradeItem objects (for multi-item operations like [g], [d], [v])
172
+
173
+ Returns:
174
+ Either a numeric score (float) or a ScoreResult enum value
175
+ """
176
+ fpad2 = " " * 4
177
+ pad = 10
178
+
179
+ while True:
180
+ # Compute current score
181
+ deductions_total = student_deductions.total_deductions(tuple(net_ids))
182
+ computed_score = max(0, max_points - deductions_total)
183
+
184
+ print("")
185
+ print("-" * 60)
186
+
187
+ # Show current deductions for this student
188
+ current_deductions = student_deductions.get_student_deductions(tuple(net_ids))
189
+ print(
190
+ fpad2 + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
191
+ )
192
+ print(fpad2 + "Current deductions:")
193
+ if current_deductions:
194
+ for deduction in current_deductions:
195
+ print(fpad2 + f" -{deduction.points}: {deduction.message}")
196
+ else:
197
+ print(fpad2 + " (None)")
198
+ print("")
199
+
200
+ ################### Build input menu #######################
201
+ # Build menu items for two-column display
202
+ # Each item is (MenuCommand or None, description) - None for special items like [Enter]
203
+ left_items = [
204
+ (MenuCommand.SKIP, "Skip student"),
205
+ ]
206
+ if allow_rebuild:
207
+ left_items.append((MenuCommand.BUILD, "Build & run"))
208
+ if allow_rerun:
209
+ desc = "Re-run" if not allow_rebuild else "Re-run (no build)"
210
+ left_items.append((MenuCommand.RERUN, desc))
211
+ left_items.append((MenuCommand.MANAGE_GRADES, "Manage grades"))
212
+ if last_graded_net_ids is not None:
213
+ left_items.append((MenuCommand.UNDO, f"Undo last ({last_graded_net_ids[0]})"))
214
+ left_items.append((MenuCommand.EXIT, "Exit grader"))
215
+
216
+ right_items = [
217
+ (MenuCommand.NEW_DEDUCTION, "New deduction"),
218
+ (MenuCommand.DELETE_DEDUCTION, "Delete deduction"),
219
+ (MenuCommand.CLEAR_DEDUCTIONS, "Clear deductions"),
220
+ (MenuCommand.CHANGE_VALUE, "Change deduction value"),
221
+ (None, "Accept score"), # [Enter] - special case
222
+ ]
223
+
224
+ # Build allowed_cmds from menu items
225
+ allowed_cmds = {}
226
+ for cmd, _ in left_items + right_items:
227
+ if cmd is not None:
228
+ allowed_cmds[cmd.value] = cmd
229
+
230
+ # Format menu items in two columns
231
+ col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
232
+ input_txt = (
233
+ TermColors.BLUE + "Enter a grade for " + names + ":" + TermColors.END + "\n"
234
+ )
235
+
236
+ # Combine left and right items row by row
237
+ max_rows = max(len(left_items), len(right_items))
238
+ for i in range(max_rows):
239
+ # Left column
240
+ if i < len(left_items):
241
+ cmd, desc = left_items[i]
242
+ key_str = cmd.key_display if cmd else "[Enter]"
243
+ col1 = (
244
+ fpad2 + TermColors.BLUE + key_str.ljust(pad) + TermColors.END + desc
245
+ )
246
+ # Pad to column width (accounting for ANSI codes)
247
+ col1_visible_len = len(fpad2) + len(key_str.ljust(pad)) + len(desc)
248
+ col1_padded = col1 + " " * (col_width - col1_visible_len)
249
+ else:
250
+ col1_padded = " " * col_width
251
+
252
+ # Right column
253
+ if i < len(right_items):
254
+ cmd, desc = right_items[i]
255
+ key_str = cmd.key_display if cmd else "[Enter]"
256
+ col2 = TermColors.BLUE + key_str.ljust(pad) + TermColors.END + desc
257
+ input_txt += col1_padded + col2 + "\n"
258
+ else:
259
+ input_txt += col1_padded.rstrip() + "\n"
260
+
261
+ # Show available deduction types to apply
262
+ input_txt += fpad2 + "Apply deduction(s):\n"
263
+ for (
264
+ deduction_id,
265
+ deduction_type,
266
+ ) in student_deductions.deduction_types.items():
267
+ input_txt += (
268
+ fpad2
269
+ + TermColors.BLUE
270
+ + f" [{deduction_id}]".ljust(pad)
271
+ + TermColors.END
272
+ + f"-{deduction_type.points}: {deduction_type.message}\n"
273
+ )
274
+
275
+ input_txt += TermColors.BLUE + ">>> " + TermColors.END
276
+
277
+ ################### Get and handle user input #######################
278
+ txt = input(input_txt)
279
+
280
+ # Check for Enter key to accept computed score
281
+ if txt == "":
282
+ print(f"Saving score: {computed_score}")
283
+ return computed_score
284
+
285
+ # Check for commands
286
+ if txt in allowed_cmds:
287
+ cmd = allowed_cmds[txt]
288
+
289
+ # Handle special cases that need to loop back
290
+ if cmd == MenuCommand.NEW_DEDUCTION:
291
+ deduction_id = student_deductions.create_deduction_type_interactive(
292
+ max_points=max_points
293
+ )
294
+ if deduction_id >= 0:
295
+ # Auto-apply the new deduction to this student
296
+ student_deductions.apply_deduction_to_student(
297
+ tuple(net_ids), deduction_id
298
+ )
299
+ continue
300
+ if cmd == MenuCommand.DELETE_DEDUCTION:
301
+ if all_items and len(all_items) > 1:
302
+ selected_item = _select_item_interactive(
303
+ all_items, "delete deduction type from"
304
+ )
305
+ if selected_item:
306
+ selected_item.student_deductions.delete_deduction_type_interactive()
307
+ else:
308
+ student_deductions.delete_deduction_type_interactive()
309
+ continue
310
+ if cmd == MenuCommand.CHANGE_VALUE:
311
+ if all_items and len(all_items) > 1:
312
+ selected_item = _select_item_interactive(
313
+ all_items, "change deduction value for"
314
+ )
315
+ if selected_item:
316
+ selected_item.student_deductions.change_deduction_value_interactive(
317
+ max_points=selected_item.max_points
318
+ )
319
+ else:
320
+ student_deductions.change_deduction_value_interactive(
321
+ max_points=max_points
322
+ )
323
+ continue
324
+ if cmd == MenuCommand.MANAGE_GRADES:
325
+ if all_items:
326
+ _manage_grades_interactive(all_items, names_by_netid)
327
+ else:
328
+ # Fallback for single item - wrap in list
329
+ # Create a minimal item-like object for backwards compatibility
330
+ class _SingleItemWrapper:
331
+ def __init__(self, student_deductions_arg):
332
+ self.student_deductions = student_deductions_arg
333
+
334
+ _manage_grades_interactive(
335
+ [_SingleItemWrapper(student_deductions)], names_by_netid
336
+ )
337
+ continue
338
+
339
+ # Commands that return directly (SKIP, BUILD, RERUN, UNDO, EXIT)
340
+ return cmd
341
+
342
+ # Check for clear command (must be exactly "0", not in a list)
343
+ if txt == MenuCommand.CLEAR_DEDUCTIONS.value:
344
+ student_deductions.clear_student_deductions(tuple(net_ids))
345
+ print("Cleared all deductions for this student.")
346
+ continue
347
+
348
+ # Check for deduction ID input
349
+ # Supports comma-separated list of deduction IDs (e.g., "1,2,3")
350
+ parts = [p.strip() for p in txt.split(",")]
351
+ valid_ids = []
352
+ all_valid = True
353
+ for part in parts:
354
+ try:
355
+ deduction_id = int(part)
356
+ # Don't allow 0 or other special commands in the list
357
+ if deduction_id <= 0:
358
+ all_valid = False
359
+ break
360
+ if deduction_id in student_deductions.deduction_types:
361
+ valid_ids.append(deduction_id)
362
+ else:
363
+ all_valid = False
364
+ break
365
+ except ValueError:
366
+ all_valid = False
367
+ break
368
+
369
+ if all_valid and valid_ids:
370
+ # Apply the deductions
371
+ for deduction_id in valid_ids:
372
+ student_deductions.apply_deduction_to_student(
373
+ tuple(net_ids), deduction_id
374
+ )
375
+ deduction_type = student_deductions.deduction_types[deduction_id]
376
+ print(
377
+ f"Applied deduction: {deduction_type.message} (-{deduction_type.points})"
378
+ )
379
+ continue
380
+
381
+ print_color(TermColors.YELLOW, "Invalid input. Try again.")
382
+
383
+
384
+ def _manage_grades_interactive(all_items, names_by_netid=None):
385
+ """Interactive menu to manage (view/delete) grades for any student.
386
+
387
+ Args:
388
+ all_items: List of GradeItem objects (to access their student_deductions)
389
+ names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
390
+ """
391
+ while True:
392
+ # Collect graded students from ALL items
393
+ all_graded_students = set()
394
+ for item in all_items:
395
+ for student_key in item.student_deductions.get_graded_students():
396
+ all_graded_students.add(student_key)
397
+
398
+ print("\n" + "=" * 60)
399
+ print_color(TermColors.BLUE, "Manage Grades")
400
+ print("=" * 60)
401
+
402
+ if not all_graded_students:
403
+ print("No students have been graded yet.")
404
+ input("Press Enter to continue...")
405
+ return
406
+
407
+ print(f"{len(all_graded_students)} student(s) graded.")
408
+ print("\nOptions:")
409
+ print(" [search] Enter search string to find student by name or net_id")
410
+ print(" [*] List all graded students")
411
+ print(" [Enter] Return to grading")
412
+
413
+ txt = input("\nSearch: ").strip()
414
+
415
+ if txt == "":
416
+ return
417
+
418
+ # Check for wildcard to list all
419
+ list_all = txt == "*"
420
+
421
+ # Search for matching students (case-insensitive)
422
+ search_lower = txt.lower()
423
+ matches = []
424
+
425
+ for net_ids in all_graded_students:
426
+ match_found = list_all # If listing all, match everything
427
+ display_parts = []
428
+
429
+ for net_id in net_ids:
430
+ # Check if search matches net_id (skip if listing all)
431
+ if not list_all and search_lower in net_id.lower():
432
+ match_found = True
433
+
434
+ # Check if search matches first/last name
435
+ if names_by_netid and net_id in names_by_netid:
436
+ first_name, last_name = names_by_netid[net_id]
437
+ if not list_all and (
438
+ search_lower in first_name.lower()
439
+ or search_lower in last_name.lower()
440
+ ):
441
+ match_found = True
442
+ display_parts.append(f"{first_name} {last_name} ({net_id})")
443
+ else:
444
+ display_parts.append(net_id)
445
+
446
+ if match_found:
447
+ matches.append((net_ids, ", ".join(display_parts)))
448
+
449
+ if not matches:
450
+ print_color(TermColors.YELLOW, f"No graded students match '{txt}'")
451
+ continue
452
+
453
+ # Sort matches by display name
454
+ matches.sort(key=lambda x: x[1].lower())
455
+
456
+ # Display matches and let user pick one
457
+ # Show combined deduction info from all items
458
+ print(f"\nFound {len(matches)} match(es):")
459
+ for i, (net_ids, display) in enumerate(matches, 1):
460
+ total_deductions = 0
461
+ total_items_graded = 0
462
+ total_deduction_count = 0
463
+ for item in all_items:
464
+ if item.student_deductions.is_student_graded(net_ids):
465
+ total_items_graded += 1
466
+ total_deductions += item.student_deductions.total_deductions(
467
+ net_ids
468
+ )
469
+ total_deduction_count += len(
470
+ item.student_deductions.get_student_deductions(net_ids)
471
+ )
472
+ # Format differently based on single vs multiple items
473
+ if len(all_items) == 1:
474
+ print(
475
+ f" [{i}] {display} "
476
+ f"({total_deduction_count} deduction(s), -{total_deductions} pts)"
477
+ )
478
+ else:
479
+ print(
480
+ f" [{i}] {display} "
481
+ f"(graded in {total_items_graded} item(s), -{total_deductions} pts total)"
482
+ )
483
+
484
+ print("\n [#] Enter number to delete that student's grade")
485
+ print(" [Enter] Cancel")
486
+
487
+ choice = input("\n>>> ").strip()
488
+
489
+ if choice == "":
490
+ continue
491
+
492
+ try:
493
+ idx = int(choice) - 1
494
+ if 0 <= idx < len(matches):
495
+ student_key, display = matches[idx]
496
+ # Confirm deletion
497
+ confirm = (
498
+ input(
499
+ f"Delete grade for {display} from ALL items? This cannot be undone. [y/N]: "
500
+ )
501
+ .strip()
502
+ .lower()
503
+ )
504
+
505
+ if confirm == "y":
506
+ # Delete from ALL items
507
+ for item in all_items:
508
+ item.student_deductions.clear_student_deductions(student_key)
509
+ print_color(
510
+ TermColors.GREEN, f"Deleted grade for {display} from all items"
511
+ )
512
+ else:
513
+ print("Cancelled.")
514
+ else:
515
+ print_color(TermColors.YELLOW, "Invalid selection.")
516
+ except ValueError:
517
+ print_color(TermColors.YELLOW, "Invalid input.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 2.6.6
3
+ Version: 2.6.10
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,343 +0,0 @@
1
- """Module for prompting users for scores during grading."""
2
-
3
- from enum import Enum, auto
4
-
5
- from .utils import TermColors, print_color
6
-
7
-
8
- class ScoreResult(Enum):
9
- """Enum representing special score input results."""
10
-
11
- SKIP = auto()
12
- REBUILD = auto()
13
- RERUN = auto()
14
- CREATE_DEDUCTION = auto()
15
- UNDO_LAST = auto()
16
- EXIT = auto()
17
-
18
-
19
- def get_score(
20
- names,
21
- max_points,
22
- *,
23
- allow_rebuild=True,
24
- allow_rerun=True,
25
- student_deductions,
26
- net_ids,
27
- last_graded_net_ids=None,
28
- names_by_netid=None,
29
- ):
30
- """Prompts the user for a score for the grade column.
31
-
32
- Args:
33
- names: Student name(s) to display
34
- max_points: Maximum points for this item
35
- allow_rebuild: Whether rebuild option is available
36
- allow_rerun: Whether rerun option is available
37
- student_deductions: StudentDeductions object
38
- net_ids: Tuple of net_ids for the current student
39
- last_graded_net_ids: Tuple of net_ids for the last graded student (for undo)
40
- names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
41
-
42
- Returns:
43
- Either a numeric score (float) or a ScoreResult enum value
44
- """
45
- fpad2 = " " * 4
46
- pad = 10
47
-
48
- while True:
49
- # Compute current score
50
- deductions_total = student_deductions.total_deductions(tuple(net_ids))
51
- computed_score = max(0, max_points - deductions_total)
52
-
53
- print("")
54
- print("-" * 60)
55
-
56
- # Show current deductions for this student
57
- current_deductions = student_deductions.get_student_deductions(tuple(net_ids))
58
- print(
59
- fpad2 + f"Current score: {TermColors.GREEN}{computed_score}{TermColors.END}"
60
- )
61
- print(fpad2 + "Current deductions:")
62
- if current_deductions:
63
- for d in current_deductions:
64
- print(fpad2 + f" -{d.points}: {d.message}")
65
- else:
66
- print(fpad2 + " (None)")
67
- print("")
68
-
69
- ################### Build input menu #######################
70
- # Build menu items for two-column display (left_items, right_items)
71
- left_items = []
72
- right_items = []
73
- allowed_cmds = {}
74
-
75
- left_items.append(("[s]", "Skip student"))
76
- allowed_cmds["s"] = ScoreResult.SKIP
77
-
78
- if allow_rebuild:
79
- left_items.append(("[b]", "Build & run"))
80
- allowed_cmds["b"] = ScoreResult.REBUILD
81
- if allow_rerun:
82
- desc = "Re-run" if not allow_rebuild else "Re-run (no build)"
83
- left_items.append(("[r]", desc))
84
- allowed_cmds["r"] = ScoreResult.RERUN
85
-
86
- # Add deduction options to right column
87
- right_items.append(("[n]", "New deduction"))
88
- allowed_cmds["n"] = "create"
89
-
90
- right_items.append(("[d]", "Delete deduction"))
91
- allowed_cmds["d"] = "delete"
92
-
93
- right_items.append(("[0]", "Clear deductions"))
94
-
95
- right_items.append(("[v]", "Change deduction value"))
96
- allowed_cmds["v"] = "change_value"
97
-
98
- # Accept score at the bottom of right column
99
- right_items.append(("[Enter]", "Accept score"))
100
-
101
- # Add manage grades and undo to left column
102
- left_items.append(("[g]", "Manage grades"))
103
- allowed_cmds["g"] = "manage"
104
-
105
- # Add undo option if there's a last graded student
106
- if last_graded_net_ids is not None:
107
- left_items.append(("[u]", f"Undo last ({last_graded_net_ids[0]})"))
108
- allowed_cmds["u"] = ScoreResult.UNDO_LAST
109
-
110
- # Add exit option at bottom of left column
111
- left_items.append(("[e]", "Exit grader"))
112
- allowed_cmds["e"] = ScoreResult.EXIT
113
-
114
- # Format menu items in two columns
115
- col_width = 38 # Each column width (2 columns * 38 = 76 < 80)
116
- input_txt = (
117
- TermColors.BLUE + "Enter a grade for " + names + ":" + TermColors.END + "\n"
118
- )
119
-
120
- # Combine left and right items row by row
121
- max_rows = max(len(left_items), len(right_items))
122
- for i in range(max_rows):
123
- # Left column
124
- if i < len(left_items):
125
- key1, desc1 = left_items[i]
126
- col1 = (
127
- fpad2 + TermColors.BLUE + key1.ljust(pad) + TermColors.END + desc1
128
- )
129
- # Pad to column width (accounting for ANSI codes)
130
- col1_visible_len = len(fpad2) + len(key1.ljust(pad)) + len(desc1)
131
- col1_padded = col1 + " " * (col_width - col1_visible_len)
132
- else:
133
- col1_padded = " " * col_width
134
-
135
- # Right column
136
- if i < len(right_items):
137
- key2, desc2 = right_items[i]
138
- col2 = TermColors.BLUE + key2.ljust(pad) + TermColors.END + desc2
139
- input_txt += col1_padded + col2 + "\n"
140
- else:
141
- input_txt += col1_padded.rstrip() + "\n"
142
-
143
- # Show available deduction types to apply
144
- input_txt += fpad2 + "Apply deduction(s):\n"
145
- for (
146
- deduction_id,
147
- deduction_type,
148
- ) in student_deductions.deduction_types.items():
149
- input_txt += (
150
- fpad2
151
- + TermColors.BLUE
152
- + f" [{deduction_id}]".ljust(pad)
153
- + TermColors.END
154
- + f"-{deduction_type.points}: {deduction_type.message}\n"
155
- )
156
-
157
- input_txt += TermColors.BLUE + ">>> " + TermColors.END
158
-
159
- ################### Get and handle user input #######################
160
- txt = input(input_txt)
161
-
162
- # Check for Enter key to accept computed score
163
- if txt == "":
164
- print(f"Saving score: {computed_score}")
165
- return computed_score
166
-
167
- # Check for commands
168
- if txt in allowed_cmds:
169
- result = allowed_cmds[txt]
170
-
171
- # Handle special cases that need to loop back
172
- if result == "create":
173
- deduction_id = student_deductions.create_deduction_type_interactive(
174
- max_points=max_points
175
- )
176
- if deduction_id >= 0:
177
- # Auto-apply the new deduction to this student
178
- student_deductions.apply_deduction_to_student(
179
- tuple(net_ids), deduction_id
180
- )
181
- continue
182
- if result == "delete":
183
- student_deductions.delete_deduction_type_interactive()
184
- continue
185
- if result == "change_value":
186
- student_deductions.change_deduction_value_interactive(
187
- max_points=max_points
188
- )
189
- continue
190
- if result == "manage":
191
- _manage_grades_interactive(student_deductions, names_by_netid)
192
- continue
193
- return result
194
-
195
- # Check for clear command (must be exactly "0", not in a list)
196
- if txt == "0":
197
- student_deductions.clear_student_deductions(tuple(net_ids))
198
- print("Cleared all deductions for this student.")
199
- continue
200
-
201
- # Check for deduction ID input
202
- # Supports comma-separated list of deduction IDs (e.g., "1,2,3")
203
- parts = [p.strip() for p in txt.split(",")]
204
- valid_ids = []
205
- all_valid = True
206
- for part in parts:
207
- try:
208
- deduction_id = int(part)
209
- # Don't allow 0 or other special commands in the list
210
- if deduction_id <= 0:
211
- all_valid = False
212
- break
213
- if deduction_id in student_deductions.deduction_types:
214
- valid_ids.append(deduction_id)
215
- else:
216
- all_valid = False
217
- break
218
- except ValueError:
219
- all_valid = False
220
- break
221
-
222
- if all_valid and valid_ids:
223
- # Apply the deductions
224
- for deduction_id in valid_ids:
225
- student_deductions.apply_deduction_to_student(
226
- tuple(net_ids), deduction_id
227
- )
228
- deduction_type = student_deductions.deduction_types[deduction_id]
229
- print(
230
- f"Applied deduction: {deduction_type.message} (-{deduction_type.points})"
231
- )
232
- continue
233
-
234
- print_color(TermColors.YELLOW, "Invalid input. Try again.")
235
-
236
-
237
- def _manage_grades_interactive(student_deductions, names_by_netid=None):
238
- """Interactive menu to manage (view/delete) grades for any student.
239
-
240
- Args:
241
- student_deductions: StudentDeductions object to manage
242
- names_by_netid: Dict mapping net_id -> (first_name, last_name) for search
243
- """
244
- while True:
245
- graded_students = student_deductions.get_graded_students()
246
-
247
- print("\n" + "=" * 60)
248
- print_color(TermColors.BLUE, "Manage Grades")
249
- print("=" * 60)
250
-
251
- if not graded_students:
252
- print("No students have been graded yet.")
253
- input("Press Enter to continue...")
254
- return
255
-
256
- print(f"{len(graded_students)} student(s) graded.")
257
- print("\nOptions:")
258
- print(" [search] Enter search string to find student by name or net_id")
259
- print(" [*] List all graded students")
260
- print(" [Enter] Return to grading")
261
-
262
- txt = input("\nSearch: ").strip()
263
-
264
- if txt == "":
265
- return
266
-
267
- # Check for wildcard to list all
268
- list_all = txt == "*"
269
-
270
- # Search for matching students (case-insensitive)
271
- search_lower = txt.lower()
272
- matches = []
273
-
274
- for net_ids in graded_students:
275
- match_found = list_all # If listing all, match everything
276
- display_parts = []
277
-
278
- for net_id in net_ids:
279
- # Check if search matches net_id (skip if listing all)
280
- if not list_all and search_lower in net_id.lower():
281
- match_found = True
282
-
283
- # Check if search matches first/last name
284
- if names_by_netid and net_id in names_by_netid:
285
- first_name, last_name = names_by_netid[net_id]
286
- if not list_all and (
287
- search_lower in first_name.lower()
288
- or search_lower in last_name.lower()
289
- ):
290
- match_found = True
291
- display_parts.append(f"{first_name} {last_name} ({net_id})")
292
- else:
293
- display_parts.append(net_id)
294
-
295
- if match_found:
296
- matches.append((net_ids, ", ".join(display_parts)))
297
-
298
- if not matches:
299
- print_color(TermColors.YELLOW, f"No graded students match '{txt}'")
300
- continue
301
-
302
- # Sort matches by display name
303
- matches.sort(key=lambda x: x[1].lower())
304
-
305
- # Display matches and let user pick one
306
- print(f"\nFound {len(matches)} match(es):")
307
- for i, (net_ids, display) in enumerate(matches, 1):
308
- deductions = student_deductions.get_student_deductions(net_ids)
309
- deduction_count = len(deductions)
310
- total_deducted = student_deductions.total_deductions(net_ids)
311
- print(
312
- f" [{i}] {display} "
313
- f"({deduction_count} deduction(s), -{total_deducted} pts)"
314
- )
315
-
316
- print("\n [#] Enter number to delete that student's grade")
317
- print(" [Enter] Cancel")
318
-
319
- choice = input("\n>>> ").strip()
320
-
321
- if choice == "":
322
- continue
323
-
324
- try:
325
- idx = int(choice) - 1
326
- if 0 <= idx < len(matches):
327
- student_key, display = matches[idx]
328
- # Confirm deletion
329
- confirm = (
330
- input(f"Delete grade for {display}? This cannot be undone. [y/N]: ")
331
- .strip()
332
- .lower()
333
- )
334
-
335
- if confirm == "y":
336
- student_deductions.clear_student_deductions(student_key)
337
- print_color(TermColors.GREEN, f"Deleted grade for {display}")
338
- else:
339
- print("Cancelled.")
340
- else:
341
- print_color(TermColors.YELLOW, "Invalid selection.")
342
- except ValueError:
343
- print_color(TermColors.YELLOW, "Invalid input.")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes