ygrader 1.2.0__tar.gz → 1.2.2__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: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Grading scripts used in BYU's Electrical and Computer Engineering Department
5
5
  Home-page: https://github.com/byu-cpe/ygrader
6
6
  Author: Jeff Goeders
@@ -4,7 +4,7 @@ setup(
4
4
  name="ygrader",
5
5
  packages=["ygrader"],
6
6
  package_data={"ygrader": ["*.ahk"]},
7
- version="1.2.0",
7
+ version="1.2.2",
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",
@@ -3,4 +3,5 @@
3
3
  from .grader import Grader, CodeSource, ScoreMode
4
4
  from .upstream_merger import UpstreamMerger
5
5
  from .utils import CallbackFailed
6
- from .sub_items import generate_subitem_csvs, ParentItem
6
+ from .grading_item_config import generate_subitem_csvs, GradeItemConfig
7
+ from .feedback import generate_feedback_zip
@@ -0,0 +1,223 @@
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 GradeItemConfig
11
+
12
+
13
+ def generate_feedback_zip(
14
+ yaml_path: pathlib.Path,
15
+ subitem_feedback_paths: Dict[str, pathlib.Path],
16
+ output_zip_path: pathlib.Path = None,
17
+ ) -> pathlib.Path:
18
+ """Generate a zip file containing feedback files for each student.
19
+
20
+ Args:
21
+ yaml_path: Path to the YAML file that can be loaded by GradeItemConfig.
22
+ subitem_feedback_paths: Mapping from subitem name to feedback YAML file path.
23
+ output_zip_path: Path for the output zip file. If None, defaults to
24
+ <yaml_dir>/feedback.zip.
25
+
26
+ Returns:
27
+ Path to the generated zip file.
28
+ """
29
+ yaml_path = pathlib.Path(yaml_path)
30
+ grade_item_config = GradeItemConfig(yaml_path)
31
+
32
+ # Get the lab name from the YAML file's parent directory
33
+ lab_name = yaml_path.stem
34
+
35
+ # Default output path
36
+ if output_zip_path is None:
37
+ output_zip_path = yaml_path.parent / "feedback.zip"
38
+
39
+ # Load all student deductions for each subitem
40
+ subitem_deductions: Dict[str, StudentDeductions] = {}
41
+ for subitem in grade_item_config.subitems:
42
+ if subitem.name in subitem_feedback_paths:
43
+ feedback_path = subitem_feedback_paths[subitem.name]
44
+ if feedback_path.exists():
45
+ subitem_deductions[subitem.name] = StudentDeductions(feedback_path)
46
+ else:
47
+ subitem_deductions[subitem.name] = StudentDeductions()
48
+ else:
49
+ subitem_deductions[subitem.name] = StudentDeductions()
50
+
51
+ # Load the first subitem's CSV to get the list of students
52
+ # (all subitems should have the same students)
53
+ first_subitem = grade_item_config.subitems[0]
54
+ if not first_subitem.csv_path.exists():
55
+ raise FileNotFoundError(
56
+ f"CSV file for subitem '{first_subitem.name}' not found at {first_subitem.csv_path}"
57
+ )
58
+
59
+ students_df = pandas.read_csv(first_subitem.csv_path)
60
+
61
+ # Create the zip file
62
+ with zipfile.ZipFile(output_zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
63
+ for _, student_row in students_df.iterrows():
64
+ first_name = str(student_row["First Name"]).strip()
65
+ last_name = str(student_row["Last Name"]).strip()
66
+ net_id = str(student_row["Net ID"]).strip()
67
+
68
+ # Generate feedback content for this student
69
+ feedback_content = _generate_student_feedback(
70
+ student_row=student_row,
71
+ grade_item_config=grade_item_config,
72
+ subitem_deductions=subitem_deductions,
73
+ )
74
+
75
+ # Generate filename
76
+ filename = (
77
+ first_name
78
+ + "_"
79
+ + last_name
80
+ + "_"
81
+ + net_id
82
+ + "_feedback-"
83
+ + lab_name
84
+ + ".txt"
85
+ )
86
+
87
+ # Add to zip file
88
+ zip_file.writestr(filename, feedback_content)
89
+
90
+ return output_zip_path
91
+
92
+
93
+ def _generate_student_feedback(
94
+ student_row: pandas.Series,
95
+ grade_item_config: GradeItemConfig,
96
+ subitem_deductions: Dict[str, StudentDeductions],
97
+ ) -> str:
98
+ """Generate the feedback text content for a single student.
99
+
100
+ Args:
101
+ student_row: A row from the student DataFrame.
102
+ grade_item_config: The GradeItemConfig object.
103
+ subitem_deductions: Mapping from subitem name to StudentDeductions.
104
+
105
+ Returns:
106
+ The formatted feedback text.
107
+ """
108
+ net_id = str(student_row["Net ID"]).strip()
109
+ first_name = str(student_row["First Name"]).strip()
110
+ last_name = str(student_row["Last Name"]).strip()
111
+
112
+ lines = []
113
+ lines.append(f"Feedback for {first_name} {last_name} ({net_id})")
114
+ lines.append("=" * 60)
115
+ lines.append("")
116
+
117
+ total_points_possible = 0
118
+ total_points_deducted = 0
119
+
120
+ # Column widths for formatting
121
+ feedback_col_width = 45
122
+ points_col_width = 15
123
+
124
+ for subitem in grade_item_config.subitems:
125
+ subitem_points_possible = subitem.points
126
+ total_points_possible += subitem_points_possible
127
+
128
+ lines.append(f"{subitem.name} ({subitem_points_possible} points)")
129
+ lines.append("-" * 60)
130
+
131
+ # Header row
132
+ header = f"{'Feedback':<{feedback_col_width}} {'Points Deducted':>{points_col_width}}"
133
+ lines.append(header)
134
+ lines.append("-" * 60)
135
+
136
+ subitem_points_deducted = 0
137
+
138
+ # Get deductions for this student in this subitem
139
+ student_deductions_obj = subitem_deductions.get(subitem.name)
140
+ if student_deductions_obj:
141
+ # Find the student's deductions (try single net_id first, then tuple)
142
+ student_key = None
143
+ if (net_id,) in student_deductions_obj.deductions_by_students:
144
+ student_key = (net_id,)
145
+ else:
146
+ # Check for multi-student keys containing this net_id
147
+ for key in student_deductions_obj.deductions_by_students.keys():
148
+ if net_id in key:
149
+ student_key = key
150
+ break
151
+
152
+ if student_key:
153
+ deductions = student_deductions_obj.deductions_by_students[student_key]
154
+ for deduction in deductions:
155
+ feedback_text = deduction.message
156
+ points = deduction.points
157
+
158
+ # Wrap long feedback text
159
+ wrapped_lines = _wrap_text(feedback_text, feedback_col_width)
160
+ for i, line_text in enumerate(wrapped_lines):
161
+ if i == 0:
162
+ row = f"{line_text:<{feedback_col_width}} {points:>{points_col_width}.1f}"
163
+ else:
164
+ row = f"{line_text:<{feedback_col_width}} {'':{points_col_width}}"
165
+ lines.append(row)
166
+
167
+ subitem_points_deducted += points
168
+
169
+ if subitem_points_deducted == 0:
170
+ lines.append(
171
+ f"{'No deductions':<{feedback_col_width}} {'0.0':>{points_col_width}}"
172
+ )
173
+
174
+ lines.append("-" * 60)
175
+ subitem_score = subitem_points_possible - subitem_points_deducted
176
+ lines.append(
177
+ f"{'Subitem Total:':<{feedback_col_width}} {subitem_score:>{points_col_width}.1f} / {subitem_points_possible:.1f}"
178
+ )
179
+ lines.append("")
180
+
181
+ total_points_deducted += subitem_points_deducted
182
+
183
+ # Total score section
184
+ lines.append("=" * 60)
185
+ total_score = total_points_possible - total_points_deducted
186
+ lines.append(
187
+ f"{'TOTAL SCORE:':<{feedback_col_width}} {total_score:>{points_col_width}.1f} / {total_points_possible:.1f}"
188
+ )
189
+ lines.append("=" * 60)
190
+
191
+ return "\n".join(lines)
192
+
193
+
194
+ def _wrap_text(text: str, width: int) -> list:
195
+ """Wrap text to fit within a given width.
196
+
197
+ Args:
198
+ text: The text to wrap.
199
+ width: Maximum width for each line.
200
+
201
+ Returns:
202
+ List of wrapped lines.
203
+ """
204
+ if len(text) <= width:
205
+ return [text]
206
+
207
+ words = text.split()
208
+ lines = []
209
+ current_line = ""
210
+
211
+ for word in words:
212
+ if not current_line:
213
+ current_line = word
214
+ elif len(current_line) + 1 + len(word) <= width:
215
+ current_line += " " + word
216
+ else:
217
+ lines.append(current_line)
218
+ current_line = word
219
+
220
+ if current_line:
221
+ lines.append(current_line)
222
+
223
+ return lines if lines else [""]
@@ -1,4 +1,4 @@
1
- """Module for handling parent items and sub-items in the grading system."""
1
+ """Module for handling grade item configs and grade subitem configs in the grading system."""
2
2
 
3
3
  import pathlib
4
4
 
@@ -9,8 +9,8 @@ from . import grades_csv
9
9
  from .utils import sanitize_filename
10
10
 
11
11
 
12
- class ParentItem:
13
- """Represents a parent item in the grading system."""
12
+ class GradeItemConfig:
13
+ """Represents a grade column configuration in the grading system, with a one-to-one mapping to a LearningSuite column"""
14
14
 
15
15
  def __init__(self, yaml_path: pathlib.Path):
16
16
  self.subitems = []
@@ -59,7 +59,7 @@ class ParentItem:
59
59
  other_data = {
60
60
  k: v for k, v in subitem_data.items() if k not in ("name", "points")
61
61
  }
62
- self.subitems.append(SubItem(yaml_path.parent, name, points, other_data))
62
+ self.subitems.append(GradeSubitemConfig(yaml_path.parent, name, points, other_data))
63
63
 
64
64
  # Parse any other data in the YAML file beyond 'parent_column' and 'columns'
65
65
  self.other_data = {
@@ -74,8 +74,8 @@ class ParentItem:
74
74
  raise ValueError(f"Sub-item with name '{name}' not found.")
75
75
 
76
76
 
77
- class SubItem:
78
- """Represents a sub-item in the grading system."""
77
+ class GradeSubitemConfig:
78
+ """Represents a grade subitem configuration in the grading system."""
79
79
 
80
80
  def __init__(
81
81
  self, dir_path: pathlib.Path, name: str, points: float, other_data: dict = None
@@ -90,17 +90,17 @@ class SubItem:
90
90
  def generate_subitem_csvs(grades_csv_path, item_yaml_path) -> None:
91
91
  """Generate CSV files for sub-items based on the provided item YAML path."""
92
92
  item_yaml_path = pathlib.Path(item_yaml_path)
93
- parent_item = ParentItem(item_yaml_path)
93
+ grade_item_config = GradeItemConfig(item_yaml_path)
94
94
  # grader = Grader("", grades_csv_path)
95
95
 
96
- grades_df = grades_csv.parse_and_check(grades_csv_path, [parent_item.csv_col_name])
96
+ grades_df = grades_csv.parse_and_check(grades_csv_path, [grade_item_config.csv_col_name])
97
97
 
98
98
  # Create subitems subdirectory
99
99
  subitems_dir = item_yaml_path.parent / "subitems"
100
100
  subitems_dir.mkdir(exist_ok=True)
101
101
 
102
102
  overwrite_all = False
103
- for subitem in parent_item.subitems:
103
+ for subitem in grade_item_config.subitems:
104
104
  subitem_csv_path = subitems_dir / f"{subitem.filename}.csv"
105
105
 
106
106
  # Check if file already exists and ask user if they want to overwrite
@@ -174,13 +174,22 @@ def open_file_in_vscode(file_path):
174
174
 
175
175
  file_path = pathlib.Path(file_path)
176
176
 
177
+ # Verify file exists before trying to open
178
+ if not file_path.exists():
179
+ error(f"File does not exist: {file_path}")
180
+
181
+ print(f"Opening {file_path} in VS Code...")
182
+
177
183
  # Open in VS Code (will steal focus)
178
- with subprocess.Popen(
184
+ result = subprocess.run(
179
185
  ["code", "--reuse-window", file_path],
180
186
  stdout=subprocess.DEVNULL,
181
187
  stderr=subprocess.DEVNULL,
182
- ):
183
- pass
188
+ check=False,
189
+ )
190
+ if result.returncode != 0:
191
+ error(f"VS Code exited with code {result.returncode}")
192
+
184
193
  # Give VS Code a moment to open, then send Ctrl+` to toggle terminal focus
185
194
  time.sleep(0.5)
186
195
 
@@ -194,7 +203,13 @@ def open_file_in_vscode(file_path):
194
203
  )
195
204
 
196
205
  if autohotkey_path.exists():
197
- subprocess.run([str(autohotkey_path), str(ahk_file)], check=False)
206
+ result = subprocess.run([str(autohotkey_path), str(ahk_file)], check=False)
207
+ if result.returncode != 0:
208
+ if not _FOCUS_WARNING_PRINTED:
209
+ warning(
210
+ f"AutoHotkey failed to send hotkey (exit code {result.returncode}). Check that AutoHotkey v2 is properly installed."
211
+ )
212
+ _FOCUS_WARNING_PRINTED = True
198
213
  else:
199
214
  if not _FOCUS_WARNING_PRINTED:
200
215
  warning(
@@ -210,7 +225,13 @@ def open_file_in_vscode(file_path):
210
225
  stderr=subprocess.DEVNULL,
211
226
  check=True,
212
227
  )
213
- subprocess.run(["xdotool", "key", "ctrl+grave"], check=False)
228
+ result = subprocess.run(["xdotool", "key", "ctrl+grave"], check=False)
229
+ if result.returncode != 0:
230
+ if not _FOCUS_WARNING_PRINTED:
231
+ warning(
232
+ f"xdotool failed to send hotkey (exit code {result.returncode}). Check that xdotool is properly installed."
233
+ )
234
+ _FOCUS_WARNING_PRINTED = True
214
235
  except subprocess.CalledProcessError:
215
236
  if not _FOCUS_WARNING_PRINTED:
216
237
  warning(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ygrader
3
- Version: 1.2.0
3
+ Version: 1.2.2
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,13 +4,14 @@ test/test_interactive.py
4
4
  test/test_unittest.py
5
5
  ygrader/__init__.py
6
6
  ygrader/deductions.py
7
+ ygrader/feedback.py
7
8
  ygrader/grader.py
8
9
  ygrader/grades_csv.py
9
10
  ygrader/grading_item.py
11
+ ygrader/grading_item_config.py
10
12
  ygrader/score_input.py
11
13
  ygrader/send_ctrl_backtick.ahk
12
14
  ygrader/student_repos.py
13
- ygrader/sub_items.py
14
15
  ygrader/upstream_merger.py
15
16
  ygrader/utils.py
16
17
  ygrader.egg-info/PKG-INFO
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes