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.
- {ygrader-1.2.0/ygrader.egg-info → ygrader-1.2.2}/PKG-INFO +1 -1
- {ygrader-1.2.0 → ygrader-1.2.2}/setup.py +1 -1
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/__init__.py +2 -1
- ygrader-1.2.2/ygrader/feedback.py +223 -0
- ygrader-1.2.0/ygrader/sub_items.py → ygrader-1.2.2/ygrader/grading_item_config.py +9 -9
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/utils.py +26 -5
- {ygrader-1.2.0 → ygrader-1.2.2/ygrader.egg-info}/PKG-INFO +1 -1
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader.egg-info/SOURCES.txt +2 -1
- {ygrader-1.2.0 → ygrader-1.2.2}/LICENSE +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/setup.cfg +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/test/test_interactive.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/test/test_unittest.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/deductions.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/grader.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/grades_csv.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/grading_item.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/score_input.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/student_repos.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader/upstream_merger.py +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-1.2.0 → ygrader-1.2.2}/ygrader.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ setup(
|
|
|
4
4
|
name="ygrader",
|
|
5
5
|
packages=["ygrader"],
|
|
6
6
|
package_data={"ygrader": ["*.ahk"]},
|
|
7
|
-
version="1.2.
|
|
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 .
|
|
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
|
|
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
|
|
13
|
-
"""Represents a
|
|
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(
|
|
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
|
|
78
|
-
"""Represents a
|
|
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
|
-
|
|
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, [
|
|
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
|
|
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
|
-
|
|
184
|
+
result = subprocess.run(
|
|
179
185
|
["code", "--reuse-window", file_path],
|
|
180
186
|
stdout=subprocess.DEVNULL,
|
|
181
187
|
stderr=subprocess.DEVNULL,
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|