ygrader 1.2.1__tar.gz → 1.2.3__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.1/ygrader.egg-info → ygrader-1.2.3}/PKG-INFO +1 -1
- {ygrader-1.2.1 → ygrader-1.2.3}/setup.py +1 -1
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/__init__.py +2 -1
- ygrader-1.2.3/ygrader/feedback.py +223 -0
- ygrader-1.2.1/ygrader/sub_items.py → ygrader-1.2.3/ygrader/grading_item_config.py +9 -9
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/utils.py +23 -1
- {ygrader-1.2.1 → ygrader-1.2.3/ygrader.egg-info}/PKG-INFO +1 -1
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader.egg-info/SOURCES.txt +2 -1
- {ygrader-1.2.1 → ygrader-1.2.3}/LICENSE +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/setup.cfg +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/test/test_interactive.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/test/test_unittest.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/deductions.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/grader.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/grades_csv.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/grading_item.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/score_input.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/send_ctrl_backtick.ahk +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/student_repos.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader/upstream_merger.py +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader.egg-info/dependency_links.txt +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/ygrader.egg-info/requires.txt +0 -0
- {ygrader-1.2.1 → ygrader-1.2.3}/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.3",
|
|
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
|
|
@@ -193,7 +193,7 @@ def open_file_in_vscode(file_path):
|
|
|
193
193
|
# Give VS Code a moment to open, then send Ctrl+` to toggle terminal focus
|
|
194
194
|
time.sleep(0.5)
|
|
195
195
|
|
|
196
|
-
# Use AutoHotkey on WSL, xdotool on Linux
|
|
196
|
+
# Use AutoHotkey on WSL, osascript on macOS, xdotool on Linux
|
|
197
197
|
if is_wsl():
|
|
198
198
|
# Get the path to the .ahk file in the package
|
|
199
199
|
package_dir = pathlib.Path(__file__).parent
|
|
@@ -216,6 +216,28 @@ def open_file_in_vscode(file_path):
|
|
|
216
216
|
f"AutoHotkey not found at {autohotkey_path}. Install AutoHotkey v2 to keep terminal focus when opening files in VS Code."
|
|
217
217
|
)
|
|
218
218
|
_FOCUS_WARNING_PRINTED = True
|
|
219
|
+
elif sys.platform == "darwin":
|
|
220
|
+
# macOS: Use osascript to send Ctrl+` (toggle terminal) to VS Code
|
|
221
|
+
applescript = """
|
|
222
|
+
tell application "System Events"
|
|
223
|
+
tell process "Code"
|
|
224
|
+
keystroke "`" using control down
|
|
225
|
+
end tell
|
|
226
|
+
end tell
|
|
227
|
+
"""
|
|
228
|
+
result = subprocess.run(
|
|
229
|
+
["osascript", "-e", applescript],
|
|
230
|
+
stdout=subprocess.DEVNULL,
|
|
231
|
+
stderr=subprocess.DEVNULL,
|
|
232
|
+
check=False,
|
|
233
|
+
)
|
|
234
|
+
if result.returncode != 0:
|
|
235
|
+
if not _FOCUS_WARNING_PRINTED:
|
|
236
|
+
warning(
|
|
237
|
+
f"osascript failed to send hotkey (exit code {result.returncode}). "
|
|
238
|
+
"Make sure VS Code is granted accessibility permissions in System Preferences."
|
|
239
|
+
)
|
|
240
|
+
_FOCUS_WARNING_PRINTED = True
|
|
219
241
|
else:
|
|
220
242
|
# Check if xdotool exists
|
|
221
243
|
try:
|
|
@@ -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
|