ygrader 1.1.27__tar.gz → 1.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.
@@ -1,13 +1,18 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 1.1.27
3
+ Version: 1.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
7
7
  Author-email: jeff.goeders@gmail.com
8
8
  License: MIT
9
- Platform: UNKNOWN
10
9
  License-File: LICENSE
11
-
12
- UNKNOWN
13
-
10
+ Requires-Dist: pandas>=1.0.0
11
+ Requires-Dist: pyyaml
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: home-page
15
+ Dynamic: license
16
+ Dynamic: license-file
17
+ Dynamic: requires-dist
18
+ Dynamic: summary
@@ -3,11 +3,12 @@ from setuptools import setup
3
3
  setup(
4
4
  name="ygrader",
5
5
  packages=["ygrader"],
6
- version="1.1.27",
6
+ package_data={"ygrader": ["*.ahk"]},
7
+ version="1.2.0",
7
8
  description="Grading scripts used in BYU's Electrical and Computer Engineering Department",
8
9
  author="Jeff Goeders",
9
10
  author_email="jeff.goeders@gmail.com",
10
11
  license="MIT",
11
12
  url="https://github.com/byu-cpe/ygrader",
12
- install_requires=["pandas>=1.0.0"],
13
+ install_requires=["pandas>=1.0.0", "pyyaml"],
13
14
  )
@@ -32,14 +32,15 @@ def test_me():
32
32
  work_path=TEST_PATH / "temp",
33
33
  )
34
34
  grader.add_item_to_grade(
35
- "lab1", run_on_milestone, 10, help_msg="This is a long string\nWith lots of advice\nFor TAs"
35
+ "lab1",
36
+ run_on_milestone,
37
+ 10,
38
+ help_msg="This is a long string\nWith lots of advice\nFor TAs",
36
39
  )
37
40
  grader.add_item_to_grade(
38
41
  "lab1m2",
39
42
  run_on_milestone,
40
- help_msg=[
41
- "msg2",
42
- ],
43
+ help_msg="msg2",
43
44
  )
44
45
  grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions.zip")
45
46
  grader.set_other_options(prep_fcn=run_on_lab)
@@ -29,10 +29,10 @@ class TestGithub(unittest.TestCase):
29
29
  grader = Grader(
30
30
  lab_name="github_test",
31
31
  grades_csv_path=TEST_RESOURCES_PATH / "grades1.csv",
32
- work_path=TEST_PATH / "temp",
32
+ work_path=TEST_PATH / "temp_github",
33
33
  )
34
- grader.add_item_to_grade("lab1", self.runner, 10)
35
- grader.add_item_to_grade("lab1m2", self.runner, 20)
34
+ grader.add_item_to_grade("lab1", self.runner, max_points=10)
35
+ grader.add_item_to_grade("lab1m2", self.runner, max_points=20)
36
36
  grader.set_submission_system_github(
37
37
  "main", TEST_RESOURCES_PATH / "github.csv", use_https=True
38
38
  )
@@ -53,14 +53,16 @@ class TestLearningSuite(unittest.TestCase):
53
53
  grader = Grader(
54
54
  lab_name="learningsuite_test",
55
55
  grades_csv_path=grades_path,
56
- work_path=TEST_PATH / "temp",
56
+ work_path=TEST_PATH / "temp_learningsuite",
57
57
  )
58
58
  grader.add_item_to_grade(
59
- csv_col_names="lab1",
59
+ csv_col_name="lab1",
60
60
  grading_fcn=self.runner,
61
- max_points=(10,),
61
+ max_points=10,
62
+ )
63
+ grader.set_submission_system_learning_suite(
64
+ TEST_RESOURCES_PATH / "submissions.zip"
62
65
  )
63
- grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions.zip")
64
66
  grader.run()
65
67
 
66
68
  self.assertTrue(filecmp.cmp(grades_path, grades_path_golden))
@@ -79,16 +81,17 @@ class TestLearningSuite(unittest.TestCase):
79
81
  grader = Grader(
80
82
  "groups_test",
81
83
  TEST_RESOURCES_PATH / "grades3.csv",
82
- work_path=TEST_PATH / "temp",
84
+ work_path=TEST_PATH / "temp_groups",
83
85
  )
84
86
  grader.add_item_to_grade(
85
- csv_col_names="l1",
87
+ csv_col_name="l1",
86
88
  grading_fcn=self.group_grader_1,
87
89
  )
88
- grader.add_item_to_grade(
89
- ("l2", "l3"), self.group_grader_2, help_msg=("rubric message 1", "rubric message 2")
90
+ grader.add_item_to_grade("l2", self.group_grader_2, help_msg="rubric message 1")
91
+ grader.add_item_to_grade("l3", self.group_grader_3, help_msg="rubric message 2")
92
+ grader.set_submission_system_learning_suite(
93
+ TEST_RESOURCES_PATH / "submissions2.zip"
90
94
  )
91
- grader.set_submission_system_learning_suite(TEST_RESOURCES_PATH / "submissions2.zip")
92
95
  grader.set_learning_suite_groups(TEST_RESOURCES_PATH / "groups3.csv")
93
96
  grader.run()
94
97
 
@@ -96,4 +99,7 @@ class TestLearningSuite(unittest.TestCase):
96
99
  return 1.5
97
100
 
98
101
  def group_grader_2(self, **kw):
99
- return (2, 3.0)
102
+ return 2
103
+
104
+ def group_grader_3(self, **kw):
105
+ return 3.0
@@ -0,0 +1,6 @@
1
+ """Default imports for package"""
2
+
3
+ from .grader import Grader, CodeSource, ScoreMode
4
+ from .upstream_merger import UpstreamMerger
5
+ from .utils import CallbackFailed
6
+ from .sub_items import generate_subitem_csvs, ParentItem
@@ -0,0 +1,401 @@
1
+ """
2
+ Deduction system for student assignments.
3
+ """
4
+
5
+ import pathlib
6
+ from typing import List, Optional
7
+
8
+ import yaml
9
+
10
+ from .utils import TermColors, print_color
11
+
12
+
13
+ class FlowList(list):
14
+ """A list subclass that will be serialized in YAML flow style (inline)."""
15
+
16
+
17
+ def flow_list_representer(dumper, data):
18
+ """Custom YAML representer for FlowList to use flow style."""
19
+ return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
20
+
21
+
22
+ yaml.add_representer(FlowList, flow_list_representer)
23
+
24
+
25
+ class DeductionType:
26
+ """A reusable deduction type that can be applied across multiple students."""
27
+
28
+ message: str
29
+ points: float = 0.0
30
+
31
+ def __init__(self, message: str, points: float = 0.0):
32
+ self.message = message
33
+ self.points = points
34
+
35
+ def __str__(self) -> str:
36
+ """String representation of the deduction type."""
37
+ if self.points != 0:
38
+ return f"{self.message} ({self.points:+.1f} points)"
39
+ return self.message
40
+
41
+
42
+ class StudentDeductions:
43
+ """Collection of all deductions for students."""
44
+
45
+ def __init__(self, yaml_path: Optional[pathlib.Path] = None):
46
+ self.deductions_by_students = {}
47
+ self.days_late_by_students = {}
48
+ self.deduction_types = {}
49
+ self.yaml_path = yaml_path
50
+
51
+ # Load from YAML file if it exists
52
+ if yaml_path is not None and yaml_path.is_file():
53
+ self._load_from_yaml()
54
+
55
+ def _save(self):
56
+ """Save the current state to YAML file if a path is set."""
57
+ if self.yaml_path is not None:
58
+ self._write_yaml()
59
+
60
+ def _load_from_yaml(self):
61
+ """Load deduction types and student deductions from the YAML file.
62
+
63
+ Expected YAML structure:
64
+ deduction_types:
65
+ - id: 0
66
+ desc: Deduction description
67
+ points: 5
68
+
69
+ student_deductions:
70
+ - net_ids: ["idA", "idB"]
71
+ deductions: [0, 3]
72
+ """
73
+ assert self.yaml_path.exists(), f"YAML file {self.yaml_path} does not exist."
74
+
75
+ with open(self.yaml_path, "r", encoding="utf-8") as f:
76
+ data = yaml.safe_load(f)
77
+
78
+ # Load deduction types if present
79
+ if data and "deduction_types" in data:
80
+ for deduction in data["deduction_types"]:
81
+ deduction_id = deduction["id"]
82
+ desc = deduction["desc"]
83
+ points = deduction["points"]
84
+
85
+ # Create a DeductionType for this deduction
86
+ deduction_type = DeductionType(message=desc, points=points)
87
+ self.deduction_types[deduction_id] = deduction_type
88
+
89
+ # Load student deductions if present
90
+ if data and "student_deductions" in data:
91
+ for entry in data["student_deductions"]:
92
+ net_ids = entry["net_ids"]
93
+ deduction_ids = entry["deductions"]
94
+
95
+ # Use tuple of net_ids as the key
96
+ student_key = tuple(net_ids)
97
+ deduction_items = []
98
+
99
+ for deduction_id in deduction_ids:
100
+ if deduction_id in self.deduction_types:
101
+ deduction_items.append(self.deduction_types[deduction_id])
102
+
103
+ self.deductions_by_students[student_key] = deduction_items
104
+
105
+ # Load days_late if present
106
+ if "days_late" in entry:
107
+ self.days_late_by_students[student_key] = entry["days_late"]
108
+
109
+ def _write_yaml(self):
110
+ """Write deduction types and student deductions to the YAML file.
111
+
112
+ Writes in the format:
113
+ deduction_types:
114
+ - id: 0
115
+ desc: Deduction description
116
+ points: 5
117
+
118
+ student_deductions:
119
+ - net_ids: ["idA", "idB"]
120
+ deductions: [0, 3]
121
+ """
122
+ data = {}
123
+
124
+ # Write deduction types
125
+ if self.deduction_types:
126
+ deduction_list = []
127
+ for deduction_id, deduction_type in self.deduction_types.items():
128
+ deduction_list.append(
129
+ {
130
+ "id": deduction_id,
131
+ "desc": deduction_type.message,
132
+ "points": deduction_type.points,
133
+ }
134
+ )
135
+ data["deduction_types"] = deduction_list
136
+
137
+ # Write student deductions (include students with deductions OR days_late)
138
+ all_student_keys = set(self.deductions_by_students.keys()) | set(
139
+ self.days_late_by_students.keys()
140
+ )
141
+ if all_student_keys:
142
+ student_deduction_list = []
143
+ for student_key in all_student_keys:
144
+ deduction_items = self.deductions_by_students.get(student_key, [])
145
+ # Find the deduction IDs for these deduction items
146
+ deduction_ids = []
147
+ for deduction_item in deduction_items:
148
+ # Find the ID of this deduction item in deduction_types
149
+ for deduction_id, dt_item in self.deduction_types.items():
150
+ if dt_item is deduction_item:
151
+ deduction_ids.append(deduction_id)
152
+ break
153
+
154
+ student_deduction_list.append(
155
+ {
156
+ "net_ids": FlowList(student_key),
157
+ "deductions": FlowList(deduction_ids),
158
+ **(
159
+ {"days_late": self.days_late_by_students[student_key]}
160
+ if student_key in self.days_late_by_students
161
+ else {}
162
+ ),
163
+ }
164
+ )
165
+ data["student_deductions"] = student_deduction_list
166
+
167
+ # Write to file
168
+ with open(self.yaml_path, "w", encoding="utf-8") as f:
169
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
170
+
171
+ def add_deduction_type(self, message: str, points: float = 0.0) -> int:
172
+ """Add a new deduction type.
173
+
174
+ Args:
175
+ message: The deduction message/description
176
+ points: Points to deduct (can be negative for bonus points)
177
+
178
+ Returns:
179
+ The ID assigned to this deduction type
180
+ """
181
+ # Find the next available ID (start at 1, reserve 0 for clear command)
182
+ if self.deduction_types:
183
+ next_id = max(self.deduction_types.keys()) + 1
184
+ else:
185
+ next_id = 1
186
+
187
+ # Create and store the deduction type
188
+ deduction_type = DeductionType(message=message, points=points)
189
+ self.deduction_types[next_id] = deduction_type
190
+
191
+ self._save()
192
+ return next_id
193
+
194
+ def create_deduction_type_interactive(self) -> int:
195
+ """Interactively prompt the user to create a new deduction type.
196
+
197
+ Returns:
198
+ The ID of the created deduction type, or -1 if cancelled.
199
+ """
200
+ print("\nCreate new deduction type (empty input to cancel):")
201
+
202
+ # Prompt for description
203
+ description = input(" Description: ").strip()
204
+ if not description:
205
+ print("Cancelled.")
206
+ return -1
207
+
208
+ # Prompt for points
209
+ while True:
210
+ points_str = input(" Points to deduct: ").strip()
211
+ if not points_str:
212
+ print("Cancelled.")
213
+ return -1
214
+ try:
215
+ points = float(points_str)
216
+ break
217
+ except ValueError:
218
+ print("Invalid number. Try again.")
219
+
220
+ # Add the new deduction type
221
+ deduction_id = self.add_deduction_type(description, points)
222
+ print(
223
+ f"Created deduction type [{deduction_id}]: {description} ({points} points)"
224
+ )
225
+
226
+ return deduction_id
227
+
228
+ def is_deduction_in_use(self, deduction_id: int) -> bool:
229
+ """Check if a deduction type is currently applied to any student.
230
+
231
+ Args:
232
+ deduction_id: The ID of the deduction type to check.
233
+
234
+ Returns:
235
+ True if any student has this deduction, False otherwise.
236
+ """
237
+ if deduction_id not in self.deduction_types:
238
+ return False
239
+
240
+ deduction_type = self.deduction_types[deduction_id]
241
+ for student_deductions in self.deductions_by_students.values():
242
+ if deduction_type in student_deductions:
243
+ return True
244
+ return False
245
+
246
+ def delete_deduction_type(self, deduction_id: int) -> bool:
247
+ """Delete a deduction type if it's not in use by any student.
248
+
249
+ Args:
250
+ deduction_id: The ID of the deduction type to delete.
251
+
252
+ Returns:
253
+ True if deleted successfully, False if in use or not found.
254
+ """
255
+ if deduction_id not in self.deduction_types:
256
+ return False
257
+
258
+ if self.is_deduction_in_use(deduction_id):
259
+ return False
260
+
261
+ del self.deduction_types[deduction_id]
262
+ self._save()
263
+ return True
264
+
265
+ def delete_deduction_type_interactive(self) -> bool:
266
+ """Interactively prompt the user to delete a deduction type.
267
+
268
+ Returns:
269
+ True if a deduction was deleted, False otherwise.
270
+ """
271
+ if not self.deduction_types:
272
+ print("No deduction types to delete.")
273
+ return False
274
+
275
+ print("\nDelete deduction type (empty input to cancel):")
276
+ print("Available deduction types:")
277
+ for deduction_id, deduction_type in self.deduction_types.items():
278
+ in_use = " (IN USE)" if self.is_deduction_in_use(deduction_id) else ""
279
+ print(
280
+ f" [{deduction_id}] -{deduction_type.points}: {deduction_type.message}{in_use}"
281
+ )
282
+
283
+ id_str = input(" Enter ID to delete: ").strip()
284
+ if not id_str:
285
+ print("Cancelled.")
286
+ return False
287
+
288
+ try:
289
+ deduction_id = int(id_str)
290
+ except ValueError:
291
+ print("Invalid ID.")
292
+ return False
293
+
294
+ if deduction_id not in self.deduction_types:
295
+ print("Deduction type not found.")
296
+ return False
297
+
298
+ if self.is_deduction_in_use(deduction_id):
299
+ print_color(
300
+ TermColors.YELLOW,
301
+ "Cannot delete - deduction is in use by one or more students.",
302
+ )
303
+ return False
304
+
305
+ deduction_type = self.deduction_types[deduction_id]
306
+ self.delete_deduction_type(deduction_id)
307
+ print(f"Deleted deduction type [{deduction_id}]: {deduction_type.message}")
308
+ return True
309
+
310
+ def get_student_deductions(self, net_ids: tuple) -> List[DeductionType]:
311
+ """Get the list of deductions applied to a student.
312
+
313
+ Args:
314
+ net_ids: Tuple of net_ids to look up.
315
+
316
+ Returns:
317
+ List of DeductionType objects applied to this student.
318
+ """
319
+ student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
320
+ return self.deductions_by_students.get(student_key, [])
321
+
322
+ def apply_deduction_to_student(self, net_ids: tuple, deduction_id: int) -> bool:
323
+ """Apply a deduction type to a student.
324
+
325
+ Args:
326
+ net_ids: Tuple of net_ids for the student.
327
+ deduction_id: The ID of the deduction type to apply.
328
+
329
+ Returns:
330
+ True if successful, False if deduction_id not found.
331
+ """
332
+ if deduction_id not in self.deduction_types:
333
+ return False
334
+
335
+ student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
336
+ if student_key not in self.deductions_by_students:
337
+ self.deductions_by_students[student_key] = []
338
+
339
+ deduction_type = self.deduction_types[deduction_id]
340
+ if deduction_type not in self.deductions_by_students[student_key]:
341
+ self.deductions_by_students[student_key].append(deduction_type)
342
+ self._save()
343
+
344
+ return True
345
+
346
+ def clear_student_deductions(self, net_ids: tuple):
347
+ """Clear all deductions for a student.
348
+
349
+ Args:
350
+ net_ids: Tuple of net_ids for the student.
351
+ """
352
+ student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
353
+ if student_key in self.deductions_by_students:
354
+ del self.deductions_by_students[student_key]
355
+ if student_key in self.days_late_by_students:
356
+ del self.days_late_by_students[student_key]
357
+ self._save()
358
+
359
+ def set_days_late(self, net_ids: tuple, days_late: int):
360
+ """Set the number of days late for a student.
361
+
362
+ Args:
363
+ net_ids: Tuple of net_ids for the student.
364
+ days_late: Number of business days late (0 or None to remove).
365
+ """
366
+ student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
367
+ if days_late and days_late > 0:
368
+ self.days_late_by_students[student_key] = days_late
369
+ elif student_key in self.days_late_by_students:
370
+ del self.days_late_by_students[student_key]
371
+ self._save()
372
+
373
+ def get_days_late(self, net_ids: tuple) -> Optional[int]:
374
+ """Get the number of days late for a student.
375
+
376
+ Args:
377
+ net_ids: Tuple of net_ids for the student.
378
+
379
+ Returns:
380
+ Number of business days late, or None if not set/on time.
381
+ """
382
+ student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
383
+ return self.days_late_by_students.get(student_key)
384
+
385
+ def total_deductions(self, net_ids: Optional[tuple] = None) -> float:
386
+ """Calculate the total deductions for a student or all students.
387
+
388
+ Args:
389
+ net_ids: Tuple of net_ids to look up. If None, returns 0.
390
+
391
+ Returns:
392
+ The total points deducted for the specified student(s).
393
+ """
394
+ if net_ids is None:
395
+ return 0.0
396
+
397
+ # Look up the student by their net_ids tuple
398
+ student_key = tuple(net_ids) if not isinstance(net_ids, tuple) else net_ids
399
+ deduction_items = self.deductions_by_students.get(student_key, [])
400
+
401
+ return sum(item.points for item in deduction_items)