PyKubeGrader 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl
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.
- {PyKubeGrader-0.1.2.dist-info → PyKubeGrader-0.1.4.dist-info}/METADATA +1 -1
- PyKubeGrader-0.1.4.dist-info/RECORD +24 -0
- PyKubeGrader-0.1.4.dist-info/entry_points.txt +2 -0
- pykubegrader/build/build_folder.py +1531 -0
- pykubegrader/widgets/__init__.py +9 -0
- pykubegrader/widgets/select_many.py +10 -0
- pykubegrader/widgets/style.py +47 -0
- pykubegrader/widgets/true_false.py +101 -0
- pykubegrader/widgets_base/multi_select.py +12 -2
- pykubegrader/widgets_base/select.py +19 -1
- PyKubeGrader-0.1.2.dist-info/RECORD +0 -20
- {PyKubeGrader-0.1.2.dist-info → PyKubeGrader-0.1.4.dist-info}/LICENSE.txt +0 -0
- {PyKubeGrader-0.1.2.dist-info → PyKubeGrader-0.1.4.dist-info}/WHEEL +0 -0
- {PyKubeGrader-0.1.2.dist-info → PyKubeGrader-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1531 @@
|
|
1
|
+
from dataclasses import dataclass, field
|
2
|
+
import os
|
3
|
+
import shutil
|
4
|
+
import nbformat
|
5
|
+
import subprocess
|
6
|
+
import sys
|
7
|
+
import argparse
|
8
|
+
import logging
|
9
|
+
import json
|
10
|
+
import re
|
11
|
+
import importlib.util
|
12
|
+
|
13
|
+
|
14
|
+
@dataclass
|
15
|
+
class NotebookProcessor:
|
16
|
+
"""
|
17
|
+
A class for processing Jupyter notebooks in a directory and its subdirectories.
|
18
|
+
|
19
|
+
Attributes:
|
20
|
+
root_folder (str): The root directory containing notebooks to process.
|
21
|
+
solutions_folder (str): The directory where processed notebooks and solutions are stored.
|
22
|
+
verbose (bool): Flag for verbose output to the console.
|
23
|
+
log (bool): Flag to enable or disable logging.
|
24
|
+
"""
|
25
|
+
|
26
|
+
root_folder: str
|
27
|
+
solutions_folder: str = field(init=False)
|
28
|
+
verbose: bool = False
|
29
|
+
log: bool = True
|
30
|
+
|
31
|
+
def __post_init__(self):
|
32
|
+
"""
|
33
|
+
Post-initialization method for setting up the `NotebookProcessor` instance.
|
34
|
+
|
35
|
+
This method is automatically called after the instance is created. It performs the following tasks:
|
36
|
+
1. Creates a solutions folder within the root directory to store processed outputs.
|
37
|
+
2. Configures logging to capture detailed information about the processing.
|
38
|
+
|
39
|
+
Raises:
|
40
|
+
OSError: If the solutions folder cannot be created due to permissions or other filesystem issues.
|
41
|
+
"""
|
42
|
+
# Define the folder to store solutions and ensure it exists
|
43
|
+
self.solutions_folder = os.path.join(self.root_folder, "_solutions")
|
44
|
+
os.makedirs(
|
45
|
+
self.solutions_folder, exist_ok=True
|
46
|
+
) # Create the folder if it doesn't exist
|
47
|
+
|
48
|
+
# Configure logging to store log messages in the solutions folder
|
49
|
+
log_file_path = os.path.join(self.solutions_folder, "notebook_processor.log")
|
50
|
+
logging.basicConfig(
|
51
|
+
filename=log_file_path, # Path to the log file
|
52
|
+
level=logging.INFO, # Log messages at INFO level and above will be recorded
|
53
|
+
format="%(asctime)s - %(levelname)s - %(message)s", # Log message format: timestamp, level, and message
|
54
|
+
)
|
55
|
+
|
56
|
+
# Initialize a global logger for the class
|
57
|
+
global logger
|
58
|
+
logger = logging.getLogger(
|
59
|
+
__name__
|
60
|
+
) # Create a logger instance specific to this module
|
61
|
+
self.logger = logger # Assign the logger instance to the class for use in instance methods
|
62
|
+
|
63
|
+
def process_notebooks(self):
|
64
|
+
"""
|
65
|
+
Recursively processes Jupyter notebooks in a given folder and its subfolders.
|
66
|
+
|
67
|
+
The function performs the following steps:
|
68
|
+
1. Iterates through all files within the root folder and subfolders.
|
69
|
+
2. Identifies Jupyter notebooks by checking file extensions (.ipynb).
|
70
|
+
3. Checks if each notebook contains assignment configuration metadata.
|
71
|
+
4. Processes notebooks that meet the criteria using `otter assign` or other defined steps.
|
72
|
+
|
73
|
+
Prerequisites:
|
74
|
+
- The `has_assignment` method should be implemented to check if a notebook
|
75
|
+
contains the required configuration for assignment processing.
|
76
|
+
- The `_process_single_notebook` method should handle the specific processing
|
77
|
+
of a single notebook, including moving it to a new folder or running
|
78
|
+
additional tools like `otter assign`.
|
79
|
+
|
80
|
+
Raises:
|
81
|
+
- OSError: If an issue occurs while accessing files or directories.
|
82
|
+
|
83
|
+
Example:
|
84
|
+
class NotebookProcessor:
|
85
|
+
def __init__(self, root_folder):
|
86
|
+
self.root_folder = root_folder
|
87
|
+
|
88
|
+
def has_assignment(self, notebook_path):
|
89
|
+
# Implementation to check for assignment configuration
|
90
|
+
return True # Replace with actual check logic
|
91
|
+
|
92
|
+
def _process_single_notebook(self, notebook_path):
|
93
|
+
# Implementation to process a single notebook
|
94
|
+
self._print_and_log(f"Processing notebook: {notebook_path}")
|
95
|
+
|
96
|
+
processor = NotebookProcessor("/path/to/root/folder")
|
97
|
+
processor.process_notebooks()
|
98
|
+
"""
|
99
|
+
ipynb_files = []
|
100
|
+
|
101
|
+
# Walk through the root folder and its subfolders
|
102
|
+
for dirpath, _, filenames in os.walk(self.root_folder):
|
103
|
+
for filename in filenames:
|
104
|
+
# Check if the file is a Jupyter notebook
|
105
|
+
if filename.endswith(".ipynb"):
|
106
|
+
notebook_path = os.path.join(dirpath, filename)
|
107
|
+
ipynb_files.append(notebook_path)
|
108
|
+
|
109
|
+
for notebook_path in ipynb_files:
|
110
|
+
# Check if the notebook has the required assignment configuration
|
111
|
+
if self.has_assignment(notebook_path):
|
112
|
+
self._print_and_log(f"notebook_path = {notebook_path}")
|
113
|
+
|
114
|
+
# Process the notebook if it meets the criteria
|
115
|
+
self._process_single_notebook(notebook_path)
|
116
|
+
|
117
|
+
def _print_and_log(self, message):
|
118
|
+
"""
|
119
|
+
Logs a message and optionally prints it to the console.
|
120
|
+
|
121
|
+
This method is used for logging important information and optionally
|
122
|
+
displaying it in the console based on the `verbose` and `log` attributes.
|
123
|
+
|
124
|
+
Args:
|
125
|
+
message (str): The message to be logged and/or printed.
|
126
|
+
|
127
|
+
Behavior:
|
128
|
+
- If `self.verbose` is True, the message will be printed to the console.
|
129
|
+
- If `self.log` is True, the message will be logged using the class's logger.
|
130
|
+
|
131
|
+
Example:
|
132
|
+
self._print_and_log("Processing completed successfully.")
|
133
|
+
|
134
|
+
Raises:
|
135
|
+
None: This method handles exceptions internally, if any arise from logging or printing.
|
136
|
+
"""
|
137
|
+
|
138
|
+
# Print the message to the console if verbosity is enabled
|
139
|
+
if self.verbose:
|
140
|
+
print(message)
|
141
|
+
|
142
|
+
# Log the message if logging is enabled
|
143
|
+
if self.log:
|
144
|
+
self.logger.info(message)
|
145
|
+
|
146
|
+
def _process_single_notebook(self, notebook_path):
|
147
|
+
"""
|
148
|
+
Processes a single Jupyter notebook.
|
149
|
+
|
150
|
+
This method handles the preparation, validation, and processing of a given notebook. It:
|
151
|
+
1. Moves the notebook to a subfolder within the solutions folder.
|
152
|
+
2. Creates temporary and destination folders for autograder and student files.
|
153
|
+
3. Identifies and processes multiple-choice questions (MCQs).
|
154
|
+
4. Runs assignment-specific tasks like executing `otter assign` and cleaning notebooks.
|
155
|
+
5. Generates solution and question files and moves them to appropriate folders.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
notebook_path (str): The file path to the Jupyter notebook to be processed.
|
159
|
+
|
160
|
+
Raises:
|
161
|
+
FileNotFoundError: If the notebook file or intermediate files are not found.
|
162
|
+
OSError: If there are issues creating or moving files/directories.
|
163
|
+
Exception: For unexpected errors during processing.
|
164
|
+
|
165
|
+
Returns:
|
166
|
+
None
|
167
|
+
"""
|
168
|
+
|
169
|
+
logging.info(f"Processing notebook: {notebook_path}")
|
170
|
+
notebook_name = os.path.splitext(os.path.basename(notebook_path))[0]
|
171
|
+
notebook_subfolder = os.path.join(self.solutions_folder, notebook_name)
|
172
|
+
os.makedirs(notebook_subfolder, exist_ok=True)
|
173
|
+
|
174
|
+
new_notebook_path = os.path.join(
|
175
|
+
notebook_subfolder, os.path.basename(notebook_path)
|
176
|
+
)
|
177
|
+
|
178
|
+
# makes a temp copy of the notebook
|
179
|
+
temp_notebook_path = os.path.join(
|
180
|
+
notebook_subfolder, f"{notebook_name}_temp.ipynb"
|
181
|
+
)
|
182
|
+
shutil.copy(notebook_path, temp_notebook_path)
|
183
|
+
|
184
|
+
# Determine the path to the autograder folder
|
185
|
+
autograder_path = os.path.join(notebook_subfolder, f"dist/autograder/")
|
186
|
+
os.makedirs(autograder_path, exist_ok=True)
|
187
|
+
|
188
|
+
# Determine the path to the student folder
|
189
|
+
student_path = os.path.join(notebook_subfolder, f"dist/student/")
|
190
|
+
os.makedirs(student_path, exist_ok=True)
|
191
|
+
|
192
|
+
if os.path.abspath(notebook_path) != os.path.abspath(new_notebook_path):
|
193
|
+
shutil.move(notebook_path, new_notebook_path)
|
194
|
+
self._print_and_log(f"Moved: {notebook_path} -> {new_notebook_path}")
|
195
|
+
else:
|
196
|
+
self._print_and_log(f"Notebook already in destination: {new_notebook_path}")
|
197
|
+
|
198
|
+
### Parse the notebook for multiple choice questions
|
199
|
+
if self.has_assignment(temp_notebook_path, "# BEGIN MULTIPLE CHOICE"):
|
200
|
+
self._print_and_log(
|
201
|
+
f"Notebook {temp_notebook_path} has multiple choice questions"
|
202
|
+
)
|
203
|
+
|
204
|
+
# Extract all the multiple choice questions
|
205
|
+
data = extract_MCQ(temp_notebook_path)
|
206
|
+
|
207
|
+
# determine the output file path
|
208
|
+
solution_path = f"{os.path.splitext(new_notebook_path)[0]}_solutions.py"
|
209
|
+
|
210
|
+
# Extract the first value cells
|
211
|
+
value = extract_raw_cells(temp_notebook_path)
|
212
|
+
|
213
|
+
data = NotebookProcessor.merge_metadata(value, data)
|
214
|
+
|
215
|
+
for data_ in data:
|
216
|
+
# Generate the solution file
|
217
|
+
self.generate_solution_MCQ(data, output_file=solution_path)
|
218
|
+
|
219
|
+
question_path = (
|
220
|
+
f"{new_notebook_path.replace(".ipynb", "")}_questions.py"
|
221
|
+
)
|
222
|
+
|
223
|
+
generate_mcq_file(data, output_file=question_path)
|
224
|
+
|
225
|
+
markers = ("# BEGIN MULTIPLE CHOICE", "# END MULTIPLE CHOICE")
|
226
|
+
|
227
|
+
replace_cells_between_markers(
|
228
|
+
data, markers, temp_notebook_path, temp_notebook_path
|
229
|
+
)
|
230
|
+
|
231
|
+
### Parse the notebook for TF questions
|
232
|
+
if self.has_assignment(temp_notebook_path, "# BEGIN TF"):
|
233
|
+
|
234
|
+
markers = ("# BEGIN TF", "# END TF")
|
235
|
+
|
236
|
+
self._print_and_log(
|
237
|
+
f"Notebook {temp_notebook_path} has True False questions"
|
238
|
+
)
|
239
|
+
|
240
|
+
# Extract all the multiple choice questions
|
241
|
+
data = extract_TF(temp_notebook_path)
|
242
|
+
|
243
|
+
# determine the output file path
|
244
|
+
solution_path = f"{os.path.splitext(new_notebook_path)[0]}_solutions.py"
|
245
|
+
|
246
|
+
# Extract the first value cells
|
247
|
+
value = extract_raw_cells(temp_notebook_path, markers[0])
|
248
|
+
|
249
|
+
data = NotebookProcessor.merge_metadata(value, data)
|
250
|
+
|
251
|
+
# for data_ in data:
|
252
|
+
# Generate the solution file
|
253
|
+
self.generate_solution_MCQ(data, output_file=solution_path)
|
254
|
+
|
255
|
+
question_path = f"{new_notebook_path.replace(".ipynb", "")}_questions.py"
|
256
|
+
|
257
|
+
generate_tf_file(data, output_file=question_path)
|
258
|
+
|
259
|
+
replace_cells_between_markers(
|
260
|
+
data, markers, temp_notebook_path, temp_notebook_path
|
261
|
+
)
|
262
|
+
|
263
|
+
### Parse the notebook for select_many questions
|
264
|
+
if self.has_assignment(temp_notebook_path, "# BEGIN SELECT MANY"):
|
265
|
+
|
266
|
+
markers = ("# BEGIN SELECT MANY", "# END SELECT MANY")
|
267
|
+
|
268
|
+
self._print_and_log(
|
269
|
+
f"Notebook {temp_notebook_path} has True False questions"
|
270
|
+
)
|
271
|
+
|
272
|
+
# Extract all the multiple choice questions
|
273
|
+
data = extract_SELECT_MANY(temp_notebook_path)
|
274
|
+
|
275
|
+
# determine the output file path
|
276
|
+
solution_path = f"{os.path.splitext(new_notebook_path)[0]}_solutions.py"
|
277
|
+
|
278
|
+
# Extract the first value cells
|
279
|
+
value = extract_raw_cells(temp_notebook_path, markers[0])
|
280
|
+
|
281
|
+
data = NotebookProcessor.merge_metadata(value, data)
|
282
|
+
|
283
|
+
# for data_ in data:
|
284
|
+
# Generate the solution file
|
285
|
+
self.generate_solution_MCQ(data, output_file=solution_path)
|
286
|
+
|
287
|
+
question_path = f"{new_notebook_path.replace(".ipynb", "")}_questions.py"
|
288
|
+
|
289
|
+
generate_select_many_file(data, output_file=question_path)
|
290
|
+
|
291
|
+
replace_cells_between_markers(
|
292
|
+
data, markers, temp_notebook_path, temp_notebook_path
|
293
|
+
)
|
294
|
+
|
295
|
+
if self.has_assignment(temp_notebook_path, "# ASSIGNMENT CONFIG"):
|
296
|
+
self.run_otter_assign(
|
297
|
+
temp_notebook_path, os.path.join(notebook_subfolder, "dist")
|
298
|
+
)
|
299
|
+
student_notebook = os.path.join(
|
300
|
+
notebook_subfolder, "dist", "student", f"{notebook_name}.ipynb"
|
301
|
+
)
|
302
|
+
self.clean_notebook(student_notebook)
|
303
|
+
NotebookProcessor.replace_temp_in_notebook(
|
304
|
+
student_notebook, student_notebook
|
305
|
+
)
|
306
|
+
autograder_notebook = os.path.join(
|
307
|
+
notebook_subfolder, "dist", "autograder", f"{notebook_name}.ipynb"
|
308
|
+
)
|
309
|
+
NotebookProcessor.replace_temp_in_notebook(
|
310
|
+
autograder_notebook, autograder_notebook
|
311
|
+
)
|
312
|
+
shutil.copy(student_notebook, self.root_folder)
|
313
|
+
self._print_and_log(
|
314
|
+
f"Copied and cleaned student notebook: {student_notebook} -> {self.root_folder}"
|
315
|
+
)
|
316
|
+
|
317
|
+
# If Otter does not run, move the student file to the main directory
|
318
|
+
if "student_notebook" not in locals():
|
319
|
+
path_ = shutil.copy(temp_notebook_path, self.root_folder)
|
320
|
+
self._print_and_log(
|
321
|
+
f"Copied and cleaned student notebook: {path_} -> {self.root_folder}"
|
322
|
+
)
|
323
|
+
|
324
|
+
# Move the solution file to the autograder folder
|
325
|
+
if "solution_path" in locals():
|
326
|
+
# gets importable file name
|
327
|
+
importable_file_name = sanitize_string(
|
328
|
+
os.path.splitext(os.path.basename(solution_path))[0]
|
329
|
+
)
|
330
|
+
|
331
|
+
# Move the solution file to the autograder folder
|
332
|
+
os.rename(
|
333
|
+
solution_path,
|
334
|
+
os.path.join(autograder_path, f"{importable_file_name}.py"),
|
335
|
+
)
|
336
|
+
|
337
|
+
if "question_path" in locals():
|
338
|
+
shutil.move(question_path, student_path)
|
339
|
+
|
340
|
+
# Remove the temp copy of the notebook
|
341
|
+
os.remove(temp_notebook_path)
|
342
|
+
|
343
|
+
# Remove all postfix from filenames in dist
|
344
|
+
NotebookProcessor.remove_postfix(autograder_path, "_solutions")
|
345
|
+
NotebookProcessor.remove_postfix(student_path, "_questions")
|
346
|
+
NotebookProcessor.remove_postfix(self.root_folder, "_temp")
|
347
|
+
|
348
|
+
### CODE TO ENSURE THAT STUDENT NOTEBOOK IS IMPORTABLE
|
349
|
+
if "question_path" in locals():
|
350
|
+
|
351
|
+
# question_root_path = os.path.dirname(question_path)
|
352
|
+
question_file_name = os.path.basename(question_path)
|
353
|
+
question_file_name_sanitized = sanitize_string(question_file_name.replace("_questions", ""))
|
354
|
+
if question_file_name_sanitized.endswith("_py"):
|
355
|
+
question_file_name_sanitized = question_file_name_sanitized[:-3] + ".py"
|
356
|
+
|
357
|
+
# Rename the file
|
358
|
+
os.rename(os.path.join(student_path, question_file_name.replace("_questions", "")), os.path.join(student_path, question_file_name_sanitized))
|
359
|
+
|
360
|
+
# Ensure the "questions" folder exists
|
361
|
+
questions_folder_jbook = os.path.join(self.root_folder, "questions")
|
362
|
+
os.makedirs(questions_folder_jbook, exist_ok=True)
|
363
|
+
|
364
|
+
# Copy the renamed file to the "questions" folder
|
365
|
+
shutil.copy(os.path.join(student_path, question_file_name_sanitized), os.path.join(questions_folder_jbook, question_file_name_sanitized))
|
366
|
+
|
367
|
+
|
368
|
+
@staticmethod
|
369
|
+
def replace_temp_in_notebook(input_file, output_file):
|
370
|
+
"""
|
371
|
+
Replaces occurrences of '_temp.ipynb' with '.ipynb' in a Jupyter Notebook.
|
372
|
+
|
373
|
+
Parameters:
|
374
|
+
input_file (str): Path to the input Jupyter Notebook file.
|
375
|
+
output_file (str): Path to the output Jupyter Notebook file.
|
376
|
+
|
377
|
+
Returns:
|
378
|
+
None: Writes the modified notebook to the output file.
|
379
|
+
"""
|
380
|
+
# Load the notebook data
|
381
|
+
with open(input_file, "r", encoding="utf-8") as f:
|
382
|
+
notebook_data = json.load(f)
|
383
|
+
|
384
|
+
# Iterate through each cell and update its content
|
385
|
+
for cell in notebook_data.get("cells", []):
|
386
|
+
if "source" in cell:
|
387
|
+
# Replace occurrences of '_temp.ipynb' in the cell source
|
388
|
+
cell["source"] = [
|
389
|
+
line.replace("_temp.ipynb", ".ipynb") for line in cell["source"]
|
390
|
+
]
|
391
|
+
|
392
|
+
# Write the updated notebook to the output file
|
393
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
394
|
+
json.dump(notebook_data, f, indent=2)
|
395
|
+
|
396
|
+
@staticmethod
|
397
|
+
def merge_metadata(raw, data):
|
398
|
+
"""
|
399
|
+
Merges raw metadata with extracted question data.
|
400
|
+
|
401
|
+
This method combines metadata from two sources: raw metadata and question data.
|
402
|
+
It ensures that the points associated with each question are appropriately distributed
|
403
|
+
and added to the final merged metadata.
|
404
|
+
|
405
|
+
Args:
|
406
|
+
raw (list): A list of dictionaries containing raw metadata.
|
407
|
+
Each dictionary must have a 'points' key with a value
|
408
|
+
that can be either a list of points or a string representing a single point value.
|
409
|
+
data (list): A list of dictionaries containing extracted question data.
|
410
|
+
Each dictionary represents a set of questions and their details.
|
411
|
+
|
412
|
+
Returns:
|
413
|
+
list: A list of dictionaries where each dictionary represents a question
|
414
|
+
with merged metadata and associated points.
|
415
|
+
|
416
|
+
Raises:
|
417
|
+
KeyError: If 'points' is missing from any raw metadata entry.
|
418
|
+
IndexError: If the number of items in `raw` and `data` do not match.
|
419
|
+
|
420
|
+
Example:
|
421
|
+
raw = [
|
422
|
+
{"points": [1.0, 2.0]},
|
423
|
+
{"points": "3.0"}
|
424
|
+
]
|
425
|
+
data = [
|
426
|
+
{"Q1": {"question_text": "What is 2+2?"}},
|
427
|
+
{"Q2": {"question_text": "What is 3+3?"}}
|
428
|
+
]
|
429
|
+
merged = merge_metadata(raw, data)
|
430
|
+
print(merged)
|
431
|
+
# Output:
|
432
|
+
# [
|
433
|
+
# {"Q1": {"question_text": "What is 2+2?", "points": 1.0}},
|
434
|
+
# {"Q2": {"question_text": "What is 3+3?", "points": 3.0}}
|
435
|
+
# ]
|
436
|
+
"""
|
437
|
+
merged_data = []
|
438
|
+
|
439
|
+
# Loop through each question set in the data
|
440
|
+
for i, _data in enumerate(data):
|
441
|
+
# Handle 'points' from raw metadata: convert single string value to a list if necessary
|
442
|
+
if isinstance(raw[i]["points"], str):
|
443
|
+
points_ = [float(raw[i]["points"])] * len(
|
444
|
+
_data
|
445
|
+
) # Distribute the same point value
|
446
|
+
else:
|
447
|
+
points_ = raw[i]["points"] # Use provided list of points
|
448
|
+
|
449
|
+
# Remove 'points' from raw metadata to avoid overwriting
|
450
|
+
raw[i].pop("points", None)
|
451
|
+
|
452
|
+
# Handle 'grade' from raw metadata
|
453
|
+
if "grade" in raw[i]:
|
454
|
+
grade_ = [raw[i]["grade"]]
|
455
|
+
|
456
|
+
# Merge each question's metadata with corresponding raw metadata
|
457
|
+
for j, (key, value) in enumerate(_data.items()):
|
458
|
+
# Combine raw metadata with question data
|
459
|
+
data[i][key] = data[i][key] | raw[i]
|
460
|
+
# Assign the correct point value to the question
|
461
|
+
data[i][key]["points"] = points_[j]
|
462
|
+
|
463
|
+
if "grade" in raw[i]:
|
464
|
+
data[i][key]["grade"] = grade_
|
465
|
+
|
466
|
+
return data
|
467
|
+
|
468
|
+
@staticmethod
|
469
|
+
def has_assignment(notebook_path, *tags):
|
470
|
+
"""
|
471
|
+
Determines if a Jupyter notebook contains any of the specified configuration tags.
|
472
|
+
|
473
|
+
This method checks for the presence of specific content in a Jupyter notebook
|
474
|
+
to identify whether it includes any of the required headings or tags.
|
475
|
+
|
476
|
+
Args:
|
477
|
+
notebook_path (str): The file path to the Jupyter notebook to be checked.
|
478
|
+
*tags (str): Variable-length argument list of tags to search for.
|
479
|
+
Defaults to ("# ASSIGNMENT CONFIG",).
|
480
|
+
|
481
|
+
Returns:
|
482
|
+
bool: True if the notebook contains any of the specified tags, False otherwise.
|
483
|
+
|
484
|
+
Dependencies:
|
485
|
+
- The `check_for_heading` function must be implemented. It should search
|
486
|
+
for specific headings or content in a notebook file and return a boolean
|
487
|
+
value indicating if any of the tags exist.
|
488
|
+
|
489
|
+
Example:
|
490
|
+
def check_for_heading(notebook_path, keywords):
|
491
|
+
# Mock implementation of content check
|
492
|
+
with open(notebook_path, 'r') as file:
|
493
|
+
content = file.read()
|
494
|
+
return any(keyword in content for keyword in keywords)
|
495
|
+
|
496
|
+
notebook_path = "path/to/notebook.ipynb"
|
497
|
+
# Check for default tags
|
498
|
+
contains_config = has_assignment(notebook_path)
|
499
|
+
self._print_and_log(f"Contains assignment config: {contains_config}")
|
500
|
+
|
501
|
+
# Check for custom tags
|
502
|
+
contains_custom = has_assignment(notebook_path, "# CUSTOM CONFIG", "# ANOTHER CONFIG")
|
503
|
+
self._print_and_log(f"Contains custom config: {contains_custom}")
|
504
|
+
"""
|
505
|
+
# Default tags if none are provided
|
506
|
+
if not tags:
|
507
|
+
tags = ["# ASSIGNMENT CONFIG", "# BEGIN MULTIPLE CHOICE"]
|
508
|
+
|
509
|
+
# Use the helper function to check for the presence of any specified tag
|
510
|
+
return check_for_heading(notebook_path, tags)
|
511
|
+
|
512
|
+
@staticmethod
|
513
|
+
def run_otter_assign(notebook_path, dist_folder):
|
514
|
+
"""
|
515
|
+
Runs `otter assign` on the given notebook and outputs to the specified distribution folder.
|
516
|
+
"""
|
517
|
+
try:
|
518
|
+
os.makedirs(dist_folder, exist_ok=True)
|
519
|
+
command = ["otter", "assign", notebook_path, dist_folder]
|
520
|
+
subprocess.run(command, check=True)
|
521
|
+
logger.info(f"Otter assign completed: {notebook_path} -> {dist_folder}")
|
522
|
+
|
523
|
+
# Remove all postfix _test from filenames in dist_folder
|
524
|
+
NotebookProcessor.remove_postfix(dist_folder)
|
525
|
+
|
526
|
+
except subprocess.CalledProcessError as e:
|
527
|
+
logger.info(f"Error running `otter assign` for {notebook_path}: {e}")
|
528
|
+
except Exception as e:
|
529
|
+
logger.info(
|
530
|
+
f"Unexpected error during `otter assign` for {notebook_path}: {e}"
|
531
|
+
)
|
532
|
+
|
533
|
+
@staticmethod
|
534
|
+
def generate_solution_MCQ(data_list, output_file="output.py"):
|
535
|
+
"""
|
536
|
+
Generates a Python file with solutions and total points based on the input data.
|
537
|
+
If the file already exists, it appends new solutions to the existing solution dictionary.
|
538
|
+
|
539
|
+
Args:
|
540
|
+
data_list (list): A list of dictionaries containing question metadata.
|
541
|
+
output_file (str): Path to the output Python file.
|
542
|
+
"""
|
543
|
+
|
544
|
+
solutions = {}
|
545
|
+
total_points = 0.0
|
546
|
+
|
547
|
+
# If the output file exists, load the existing solutions and total_points
|
548
|
+
if os.path.exists(output_file):
|
549
|
+
spec = importlib.util.spec_from_file_location(
|
550
|
+
"existing_module", output_file
|
551
|
+
)
|
552
|
+
existing_module = importlib.util.module_from_spec(spec)
|
553
|
+
spec.loader.exec_module(existing_module) # Load the module dynamically
|
554
|
+
|
555
|
+
# Attempt to read existing solutions and total_points
|
556
|
+
if hasattr(existing_module, "solutions"):
|
557
|
+
solutions.update(existing_module.solutions)
|
558
|
+
if hasattr(existing_module, "total_points"):
|
559
|
+
total_points += existing_module.total_points
|
560
|
+
|
561
|
+
# Process new question data and update solutions and total_points
|
562
|
+
for question_set in data_list:
|
563
|
+
for key, question_data in question_set.items():
|
564
|
+
solution_key = f"q{question_data['question number']}-{question_data['subquestion_number']}-{key}"
|
565
|
+
solutions[solution_key] = question_data["solution"]
|
566
|
+
total_points += question_data["points"]
|
567
|
+
|
568
|
+
# Write updated total_points and solutions back to the file
|
569
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
570
|
+
f.write("from typing import Any\n\n")
|
571
|
+
f.write(f"total_points: float = {total_points}\n\n")
|
572
|
+
|
573
|
+
f.write("solutions: dict[str, Any] = {\n")
|
574
|
+
for key, solution in solutions.items():
|
575
|
+
# For safety, we assume solutions are strings, but if not, repr would be safer
|
576
|
+
f.write(f' "{key}": {repr(solution)},\n')
|
577
|
+
f.write("}\n")
|
578
|
+
|
579
|
+
@staticmethod
|
580
|
+
def generate_solution_MCQ(data_list, output_file="output.py"):
|
581
|
+
"""
|
582
|
+
Generates a Python file with solutions and total points based on the input data.
|
583
|
+
If the file already exists, it appends new solutions to the existing solution dictionary.
|
584
|
+
|
585
|
+
Args:
|
586
|
+
data_list (list): A list of dictionaries containing question metadata.
|
587
|
+
output_file (str): Path to the output Python file.
|
588
|
+
"""
|
589
|
+
|
590
|
+
solutions = {}
|
591
|
+
total_points = 0.0
|
592
|
+
|
593
|
+
# If the output file exists, load the existing solutions and total_points
|
594
|
+
if os.path.exists(output_file):
|
595
|
+
spec = importlib.util.spec_from_file_location(
|
596
|
+
"existing_module", output_file
|
597
|
+
)
|
598
|
+
existing_module = importlib.util.module_from_spec(spec)
|
599
|
+
spec.loader.exec_module(existing_module) # Load the module dynamically
|
600
|
+
|
601
|
+
# Attempt to read existing solutions and total_points
|
602
|
+
if hasattr(existing_module, "solutions"):
|
603
|
+
solutions.update(existing_module.solutions)
|
604
|
+
if hasattr(existing_module, "total_points"):
|
605
|
+
total_points += existing_module.total_points
|
606
|
+
|
607
|
+
# Process new question data and update solutions and total_points
|
608
|
+
for question_set in data_list:
|
609
|
+
for key, question_data in question_set.items():
|
610
|
+
solution_key = f"q{question_data['question number']}-{question_data['subquestion_number']}-{key}"
|
611
|
+
solutions[solution_key] = question_data["solution"]
|
612
|
+
total_points += question_data["points"]
|
613
|
+
|
614
|
+
# Write updated total_points and solutions back to the file
|
615
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
616
|
+
f.write("from typing import Any\n\n")
|
617
|
+
f.write(f"total_points: float = {total_points}\n\n")
|
618
|
+
|
619
|
+
f.write("solutions: dict[str, Any] = {\n")
|
620
|
+
for key, solution in solutions.items():
|
621
|
+
# For safety, we assume solutions are strings, but if not, repr would be safer
|
622
|
+
f.write(f' "{key}": {repr(solution)},\n')
|
623
|
+
f.write("}\n")
|
624
|
+
|
625
|
+
def extract_MCQ(ipynb_file):
|
626
|
+
"""
|
627
|
+
Extracts questions from markdown cells and organizes them as a nested dictionary,
|
628
|
+
including subquestion numbers.
|
629
|
+
|
630
|
+
Args:
|
631
|
+
ipynb_file (str): Path to the .ipynb file.
|
632
|
+
|
633
|
+
Returns:
|
634
|
+
dict: A nested dictionary where the first-level key is the question name (text after ##),
|
635
|
+
and the value is a dictionary with keys: 'name', 'subquestion_number',
|
636
|
+
'question_text', 'OPTIONS', and 'solution'.
|
637
|
+
"""
|
638
|
+
try:
|
639
|
+
# Load the notebook file
|
640
|
+
with open(ipynb_file, "r", encoding="utf-8") as f:
|
641
|
+
notebook_data = json.load(f)
|
642
|
+
|
643
|
+
cells = notebook_data.get("cells", [])
|
644
|
+
results = {}
|
645
|
+
within_section = False
|
646
|
+
subquestion_number = 0 # Counter for subquestions
|
647
|
+
|
648
|
+
for cell in cells:
|
649
|
+
if cell.get("cell_type") == "raw":
|
650
|
+
# Check for the start and end labels in raw cells
|
651
|
+
raw_content = "".join(cell.get("source", []))
|
652
|
+
if "# BEGIN MULTIPLE CHOICE" in raw_content:
|
653
|
+
within_section = True
|
654
|
+
subquestion_number = (
|
655
|
+
0 # Reset counter at the start of a new section
|
656
|
+
)
|
657
|
+
continue
|
658
|
+
elif "# END MULTIPLE CHOICE" in raw_content:
|
659
|
+
within_section = False
|
660
|
+
continue
|
661
|
+
|
662
|
+
if within_section and cell.get("cell_type") == "markdown":
|
663
|
+
# Parse markdown cell content
|
664
|
+
markdown_content = "".join(cell.get("source", []))
|
665
|
+
|
666
|
+
# Extract title (## heading)
|
667
|
+
title_match = re.search(
|
668
|
+
r"^##\s*(.+)", markdown_content, re.MULTILINE
|
669
|
+
)
|
670
|
+
title = title_match.group(1).strip() if title_match else None
|
671
|
+
|
672
|
+
if title:
|
673
|
+
subquestion_number += (
|
674
|
+
1 # Increment the subquestion number for each question
|
675
|
+
)
|
676
|
+
|
677
|
+
# Extract question text (### heading)
|
678
|
+
question_text_match = re.search(
|
679
|
+
r"^###\s*\*\*(.+)\*\*", markdown_content, re.MULTILINE
|
680
|
+
)
|
681
|
+
question_text = (
|
682
|
+
question_text_match.group(1).strip()
|
683
|
+
if question_text_match
|
684
|
+
else None
|
685
|
+
)
|
686
|
+
|
687
|
+
# Extract OPTIONS (lines after #### options)
|
688
|
+
options_match = re.search(
|
689
|
+
r"####\s*options\s*(.+?)(?=####|$)",
|
690
|
+
markdown_content,
|
691
|
+
re.DOTALL | re.IGNORECASE,
|
692
|
+
)
|
693
|
+
options = (
|
694
|
+
[
|
695
|
+
line.strip()
|
696
|
+
for line in options_match.group(1).strip().splitlines()
|
697
|
+
if line.strip()
|
698
|
+
]
|
699
|
+
if options_match
|
700
|
+
else []
|
701
|
+
)
|
702
|
+
|
703
|
+
# Extract solution (line after #### SOLUTION)
|
704
|
+
solution_match = re.search(
|
705
|
+
r"####\s*SOLUTION\s*(.+)", markdown_content, re.IGNORECASE
|
706
|
+
)
|
707
|
+
solution = (
|
708
|
+
solution_match.group(1).strip() if solution_match else None
|
709
|
+
)
|
710
|
+
|
711
|
+
# Create nested dictionary for the question
|
712
|
+
results[title] = {
|
713
|
+
"name": title,
|
714
|
+
"subquestion_number": subquestion_number,
|
715
|
+
"question_text": question_text,
|
716
|
+
"OPTIONS": options,
|
717
|
+
"solution": solution,
|
718
|
+
}
|
719
|
+
|
720
|
+
return results
|
721
|
+
|
722
|
+
except FileNotFoundError:
|
723
|
+
print(f"File {ipynb_file} not found.")
|
724
|
+
return {}
|
725
|
+
except json.JSONDecodeError:
|
726
|
+
print("Invalid JSON in notebook file.")
|
727
|
+
return {}
|
728
|
+
|
729
|
+
@staticmethod
|
730
|
+
def remove_postfix(dist_folder, suffix="_temp"):
|
731
|
+
logging.info(f"Removing postfix '{suffix}' from filenames in {dist_folder}")
|
732
|
+
for root, _, files in os.walk(dist_folder):
|
733
|
+
for file in files:
|
734
|
+
if suffix in file:
|
735
|
+
old_file_path = os.path.join(root, file)
|
736
|
+
new_file_path = os.path.join(root, file.replace(suffix, ""))
|
737
|
+
os.rename(old_file_path, new_file_path)
|
738
|
+
logging.info(f"Renamed: {old_file_path} -> {new_file_path}")
|
739
|
+
|
740
|
+
@staticmethod
|
741
|
+
def clean_notebook(notebook_path):
|
742
|
+
"""
|
743
|
+
Cleans a Jupyter notebook to remove unwanted cells and set cell metadata.
|
744
|
+
"""
|
745
|
+
clean_notebook(notebook_path)
|
746
|
+
|
747
|
+
|
748
|
+
def extract_raw_cells(ipynb_file, heading="# BEGIN MULTIPLE CHOICE"):
|
749
|
+
"""
|
750
|
+
Extracts all metadata from value cells in a Jupyter Notebook file for a specified heading.
|
751
|
+
|
752
|
+
Args:
|
753
|
+
ipynb_file (str): Path to the .ipynb file.
|
754
|
+
heading (str): The heading to search for in value cells.
|
755
|
+
|
756
|
+
Returns:
|
757
|
+
list of dict: A list of dictionaries containing extracted metadata for each heading occurrence.
|
758
|
+
"""
|
759
|
+
try:
|
760
|
+
with open(ipynb_file, "r", encoding="utf-8") as f:
|
761
|
+
notebook_data = json.load(f)
|
762
|
+
|
763
|
+
# Extract value cell content
|
764
|
+
raw_cells = [
|
765
|
+
"".join(
|
766
|
+
cell.get("source", [])
|
767
|
+
) # Join multiline sources into a single string
|
768
|
+
for cell in notebook_data.get("cells", [])
|
769
|
+
if cell.get("cell_type") == "raw"
|
770
|
+
]
|
771
|
+
|
772
|
+
# Process each value cell to extract metadata
|
773
|
+
metadata_list = []
|
774
|
+
for raw_cell in raw_cells:
|
775
|
+
metadata_list.extend(_extract_metadata_from_heading(raw_cell, heading))
|
776
|
+
|
777
|
+
return metadata_list
|
778
|
+
|
779
|
+
except FileNotFoundError:
|
780
|
+
print(f"File {ipynb_file} not found.")
|
781
|
+
return []
|
782
|
+
except json.JSONDecodeError:
|
783
|
+
print("Invalid JSON in notebook file.")
|
784
|
+
return []
|
785
|
+
|
786
|
+
|
787
|
+
def _extract_metadata_from_heading(raw_cell, heading="# BEGIN MULTIPLE CHOICE"):
|
788
|
+
"""
|
789
|
+
Extracts metadata for a single value cell string each time the heading is found.
|
790
|
+
|
791
|
+
Args:
|
792
|
+
raw_cell (str): String containing value cell content.
|
793
|
+
heading (str): The heading to identify sections.
|
794
|
+
|
795
|
+
Returns:
|
796
|
+
list of dict: A list of dictionaries containing extracted key-value pairs.
|
797
|
+
"""
|
798
|
+
metadata_list = []
|
799
|
+
lines = raw_cell.split("\n")
|
800
|
+
current_metadata = None
|
801
|
+
|
802
|
+
for line in lines:
|
803
|
+
if line.startswith(heading):
|
804
|
+
if current_metadata:
|
805
|
+
metadata_list.append(current_metadata) # Save previous metadata
|
806
|
+
current_metadata = {} # Start new metadata block
|
807
|
+
elif line.startswith("##") and current_metadata is not None:
|
808
|
+
# Extract key and value from lines
|
809
|
+
key, value = line[3:].split(":", 1)
|
810
|
+
current_metadata[key.strip()] = value.strip()
|
811
|
+
|
812
|
+
if current_metadata: # Append the last metadata block
|
813
|
+
metadata_list.append(current_metadata)
|
814
|
+
|
815
|
+
return metadata_list
|
816
|
+
|
817
|
+
|
818
|
+
def extract_SELECT_MANY(ipynb_file):
|
819
|
+
"""
|
820
|
+
Extracts questions marked by `# BEGIN SELECT MANY` and `# END SELECT MANY` in markdown cells,
|
821
|
+
including all lines under the SOLUTION header until the first blank line or whitespace-only line.
|
822
|
+
|
823
|
+
Args:
|
824
|
+
ipynb_file (str): Path to the .ipynb file.
|
825
|
+
|
826
|
+
Returns:
|
827
|
+
list: A list of dictionaries, where each dictionary corresponds to questions within
|
828
|
+
a section. Each dictionary contains parsed questions with details like
|
829
|
+
'name', 'subquestion_number', 'question_text', and 'solution'.
|
830
|
+
"""
|
831
|
+
try:
|
832
|
+
# Load the notebook file
|
833
|
+
with open(ipynb_file, "r", encoding="utf-8") as f:
|
834
|
+
notebook_data = json.load(f)
|
835
|
+
|
836
|
+
cells = notebook_data.get("cells", [])
|
837
|
+
sections = [] # List to store results for each section
|
838
|
+
current_section = {} # Current section being processed
|
839
|
+
within_section = False
|
840
|
+
subquestion_number = 0 # Counter for subquestions
|
841
|
+
|
842
|
+
for cell in cells:
|
843
|
+
if cell.get("cell_type") == "raw":
|
844
|
+
# Check for the start and end labels in raw cells
|
845
|
+
raw_content = "".join(cell.get("source", []))
|
846
|
+
if "# BEGIN SELECT MANY" in raw_content:
|
847
|
+
within_section = True
|
848
|
+
subquestion_number = (
|
849
|
+
0 # Reset counter at the start of a new section
|
850
|
+
)
|
851
|
+
current_section = {} # Prepare a new section dictionary
|
852
|
+
continue
|
853
|
+
elif "# END SELECT MANY" in raw_content:
|
854
|
+
within_section = False
|
855
|
+
if current_section:
|
856
|
+
sections.append(current_section) # Save the current section
|
857
|
+
continue
|
858
|
+
|
859
|
+
if within_section and cell.get("cell_type") == "markdown":
|
860
|
+
# Parse markdown cell content
|
861
|
+
markdown_content = "".join(cell.get("source", []))
|
862
|
+
|
863
|
+
# Extract title (## heading)
|
864
|
+
title_match = re.search(r"^##\s*(.+)", markdown_content, re.MULTILINE)
|
865
|
+
title = title_match.group(1).strip() if title_match else None
|
866
|
+
|
867
|
+
if title:
|
868
|
+
subquestion_number += (
|
869
|
+
1 # Increment subquestion number for each question
|
870
|
+
)
|
871
|
+
|
872
|
+
# Extract question text (### heading)
|
873
|
+
question_text_match = re.search(
|
874
|
+
r"^###\s*\*\*(.+)\*\*", markdown_content, re.MULTILINE
|
875
|
+
)
|
876
|
+
question_text = (
|
877
|
+
question_text_match.group(1).strip()
|
878
|
+
if question_text_match
|
879
|
+
else None
|
880
|
+
)
|
881
|
+
|
882
|
+
# Extract OPTIONS (lines after #### options)
|
883
|
+
options_match = re.search(
|
884
|
+
r"####\s*options\s*(.+?)(?=####|$)",
|
885
|
+
markdown_content,
|
886
|
+
re.DOTALL | re.IGNORECASE,
|
887
|
+
)
|
888
|
+
options = (
|
889
|
+
[
|
890
|
+
line.strip()
|
891
|
+
for line in options_match.group(1).strip().splitlines()
|
892
|
+
if line.strip()
|
893
|
+
]
|
894
|
+
if options_match
|
895
|
+
else []
|
896
|
+
)
|
897
|
+
|
898
|
+
# Extract all lines under the SOLUTION header
|
899
|
+
solution_start = markdown_content.find("#### SOLUTION")
|
900
|
+
if solution_start != -1:
|
901
|
+
solution = []
|
902
|
+
lines = markdown_content[solution_start:].splitlines()
|
903
|
+
for line in lines[1:]: # Skip the "#### SOLUTION" line
|
904
|
+
if line.strip(): # Non-blank line after trimming spaces
|
905
|
+
solution.append(line.strip())
|
906
|
+
else:
|
907
|
+
break
|
908
|
+
|
909
|
+
# Add question details to the current section
|
910
|
+
current_section[title] = {
|
911
|
+
"name": title,
|
912
|
+
"subquestion_number": subquestion_number,
|
913
|
+
"question_text": question_text,
|
914
|
+
"solution": solution,
|
915
|
+
"OPTIONS": options,
|
916
|
+
}
|
917
|
+
|
918
|
+
return sections
|
919
|
+
|
920
|
+
except FileNotFoundError:
|
921
|
+
print(f"File {ipynb_file} not found.")
|
922
|
+
return []
|
923
|
+
except json.JSONDecodeError:
|
924
|
+
print("Invalid JSON in notebook file.")
|
925
|
+
return []
|
926
|
+
|
927
|
+
|
928
|
+
def extract_TF(ipynb_file):
|
929
|
+
"""
|
930
|
+
Extracts True False questions from markdown cells within sections marked by
|
931
|
+
`# BEGIN TF` and `# END TF`.
|
932
|
+
|
933
|
+
Args:
|
934
|
+
ipynb_file (str): Path to the .ipynb file.
|
935
|
+
|
936
|
+
Returns:
|
937
|
+
list: A list of dictionaries, where each dictionary corresponds to questions within
|
938
|
+
a section. Each dictionary contains parsed questions with details like
|
939
|
+
'name', 'subquestion_number', 'question_text', and 'solution'.
|
940
|
+
"""
|
941
|
+
try:
|
942
|
+
# Load the notebook file
|
943
|
+
with open(ipynb_file, "r", encoding="utf-8") as f:
|
944
|
+
notebook_data = json.load(f)
|
945
|
+
|
946
|
+
cells = notebook_data.get("cells", [])
|
947
|
+
sections = [] # List to store results for each section
|
948
|
+
current_section = {} # Current section being processed
|
949
|
+
within_section = False
|
950
|
+
subquestion_number = 0 # Counter for subquestions
|
951
|
+
|
952
|
+
for cell in cells:
|
953
|
+
if cell.get("cell_type") == "raw":
|
954
|
+
# Check for the start and end labels in raw cells
|
955
|
+
raw_content = "".join(cell.get("source", []))
|
956
|
+
if "# BEGIN TF" in raw_content:
|
957
|
+
within_section = True
|
958
|
+
subquestion_number = (
|
959
|
+
0 # Reset counter at the start of a new section
|
960
|
+
)
|
961
|
+
current_section = {} # Prepare a new section dictionary
|
962
|
+
continue
|
963
|
+
elif "# END TF" in raw_content:
|
964
|
+
within_section = False
|
965
|
+
if current_section:
|
966
|
+
sections.append(current_section) # Save the current section
|
967
|
+
continue
|
968
|
+
|
969
|
+
if within_section and cell.get("cell_type") == "markdown":
|
970
|
+
# Parse markdown cell content
|
971
|
+
markdown_content = "".join(cell.get("source", []))
|
972
|
+
|
973
|
+
# Extract title (## heading)
|
974
|
+
title_match = re.search(r"^##\s*(.+)", markdown_content, re.MULTILINE)
|
975
|
+
title = title_match.group(1).strip() if title_match else None
|
976
|
+
|
977
|
+
if title:
|
978
|
+
subquestion_number += (
|
979
|
+
1 # Increment subquestion number for each question
|
980
|
+
)
|
981
|
+
|
982
|
+
# Extract question text (### heading)
|
983
|
+
question_text_match = re.search(
|
984
|
+
r"^###\s*\*\*(.+)\*\*", markdown_content, re.MULTILINE
|
985
|
+
)
|
986
|
+
question_text = (
|
987
|
+
question_text_match.group(1).strip()
|
988
|
+
if question_text_match
|
989
|
+
else None
|
990
|
+
)
|
991
|
+
|
992
|
+
# Extract solution (line after #### SOLUTION)
|
993
|
+
solution_match = re.search(
|
994
|
+
r"####\s*SOLUTION\s*(.+)", markdown_content, re.IGNORECASE
|
995
|
+
)
|
996
|
+
solution = (
|
997
|
+
solution_match.group(1).strip() if solution_match else None
|
998
|
+
)
|
999
|
+
|
1000
|
+
# Add question details to the current section
|
1001
|
+
current_section[title] = {
|
1002
|
+
"name": title,
|
1003
|
+
"subquestion_number": subquestion_number,
|
1004
|
+
"question_text": question_text,
|
1005
|
+
"solution": solution,
|
1006
|
+
}
|
1007
|
+
|
1008
|
+
return sections
|
1009
|
+
|
1010
|
+
except FileNotFoundError:
|
1011
|
+
print(f"File {ipynb_file} not found.")
|
1012
|
+
return []
|
1013
|
+
except json.JSONDecodeError:
|
1014
|
+
print("Invalid JSON in notebook file.")
|
1015
|
+
return []
|
1016
|
+
|
1017
|
+
|
1018
|
+
def extract_MCQ(ipynb_file):
|
1019
|
+
"""
|
1020
|
+
Extracts multiple-choice questions from markdown cells within sections marked by
|
1021
|
+
`# BEGIN MULTIPLE CHOICE` and `# END MULTIPLE CHOICE`.
|
1022
|
+
|
1023
|
+
Args:
|
1024
|
+
ipynb_file (str): Path to the .ipynb file.
|
1025
|
+
|
1026
|
+
Returns:
|
1027
|
+
list: A list of dictionaries, where each dictionary corresponds to questions within
|
1028
|
+
a section. Each dictionary contains parsed questions with details like
|
1029
|
+
'name', 'subquestion_number', 'question_text', 'OPTIONS', and 'solution'.
|
1030
|
+
"""
|
1031
|
+
try:
|
1032
|
+
# Load the notebook file
|
1033
|
+
with open(ipynb_file, "r", encoding="utf-8") as f:
|
1034
|
+
notebook_data = json.load(f)
|
1035
|
+
|
1036
|
+
cells = notebook_data.get("cells", [])
|
1037
|
+
sections = [] # List to store results for each section
|
1038
|
+
current_section = {} # Current section being processed
|
1039
|
+
within_section = False
|
1040
|
+
subquestion_number = 0 # Counter for subquestions
|
1041
|
+
|
1042
|
+
for cell in cells:
|
1043
|
+
if cell.get("cell_type") == "raw":
|
1044
|
+
# Check for the start and end labels in raw cells
|
1045
|
+
raw_content = "".join(cell.get("source", []))
|
1046
|
+
if "# BEGIN MULTIPLE CHOICE" in raw_content:
|
1047
|
+
within_section = True
|
1048
|
+
subquestion_number = (
|
1049
|
+
0 # Reset counter at the start of a new section
|
1050
|
+
)
|
1051
|
+
current_section = {} # Prepare a new section dictionary
|
1052
|
+
continue
|
1053
|
+
elif "# END MULTIPLE CHOICE" in raw_content:
|
1054
|
+
within_section = False
|
1055
|
+
if current_section:
|
1056
|
+
sections.append(current_section) # Save the current section
|
1057
|
+
continue
|
1058
|
+
|
1059
|
+
if within_section and cell.get("cell_type") == "markdown":
|
1060
|
+
# Parse markdown cell content
|
1061
|
+
markdown_content = "".join(cell.get("source", []))
|
1062
|
+
|
1063
|
+
# Extract title (## heading)
|
1064
|
+
title_match = re.search(r"^##\s*(.+)", markdown_content, re.MULTILINE)
|
1065
|
+
title = title_match.group(1).strip() if title_match else None
|
1066
|
+
|
1067
|
+
if title:
|
1068
|
+
subquestion_number += (
|
1069
|
+
1 # Increment subquestion number for each question
|
1070
|
+
)
|
1071
|
+
|
1072
|
+
# Extract question text (### heading)
|
1073
|
+
question_text_match = re.search(
|
1074
|
+
r"^###\s*\*\*(.+)\*\*", markdown_content, re.MULTILINE
|
1075
|
+
)
|
1076
|
+
question_text = (
|
1077
|
+
question_text_match.group(1).strip()
|
1078
|
+
if question_text_match
|
1079
|
+
else None
|
1080
|
+
)
|
1081
|
+
|
1082
|
+
# Extract OPTIONS (lines after #### options)
|
1083
|
+
options_match = re.search(
|
1084
|
+
r"####\s*options\s*(.+?)(?=####|$)",
|
1085
|
+
markdown_content,
|
1086
|
+
re.DOTALL | re.IGNORECASE,
|
1087
|
+
)
|
1088
|
+
options = (
|
1089
|
+
[
|
1090
|
+
line.strip()
|
1091
|
+
for line in options_match.group(1).strip().splitlines()
|
1092
|
+
if line.strip()
|
1093
|
+
]
|
1094
|
+
if options_match
|
1095
|
+
else []
|
1096
|
+
)
|
1097
|
+
|
1098
|
+
# Extract solution (line after #### SOLUTION)
|
1099
|
+
solution_match = re.search(
|
1100
|
+
r"####\s*SOLUTION\s*(.+)", markdown_content, re.IGNORECASE
|
1101
|
+
)
|
1102
|
+
solution = (
|
1103
|
+
solution_match.group(1).strip() if solution_match else None
|
1104
|
+
)
|
1105
|
+
|
1106
|
+
# Add question details to the current section
|
1107
|
+
current_section[title] = {
|
1108
|
+
"name": title,
|
1109
|
+
"subquestion_number": subquestion_number,
|
1110
|
+
"question_text": question_text,
|
1111
|
+
"OPTIONS": options,
|
1112
|
+
"solution": solution,
|
1113
|
+
}
|
1114
|
+
|
1115
|
+
return sections
|
1116
|
+
|
1117
|
+
except FileNotFoundError:
|
1118
|
+
print(f"File {ipynb_file} not found.")
|
1119
|
+
return []
|
1120
|
+
except json.JSONDecodeError:
|
1121
|
+
print("Invalid JSON in notebook file.")
|
1122
|
+
return []
|
1123
|
+
|
1124
|
+
|
1125
|
+
def check_for_heading(notebook_path, search_strings):
|
1126
|
+
"""
|
1127
|
+
Checks if a Jupyter notebook contains a heading cell whose source matches any of the given strings.
|
1128
|
+
"""
|
1129
|
+
try:
|
1130
|
+
with open(notebook_path, "r", encoding="utf-8") as f:
|
1131
|
+
notebook = nbformat.read(f, as_version=4)
|
1132
|
+
for cell in notebook.cells:
|
1133
|
+
if cell.cell_type == "raw" and cell.source.startswith("#"):
|
1134
|
+
if any(
|
1135
|
+
search_string in cell.source for search_string in search_strings
|
1136
|
+
):
|
1137
|
+
return True
|
1138
|
+
except Exception as e:
|
1139
|
+
logger.info(f"Error reading notebook {notebook_path}: {e}")
|
1140
|
+
return False
|
1141
|
+
|
1142
|
+
|
1143
|
+
def clean_notebook(notebook_path):
|
1144
|
+
"""
|
1145
|
+
Removes specific cells and makes Markdown cells non-editable and non-deletable by updating their metadata.
|
1146
|
+
"""
|
1147
|
+
try:
|
1148
|
+
with open(notebook_path, "r", encoding="utf-8") as f:
|
1149
|
+
notebook = nbformat.read(f, as_version=4)
|
1150
|
+
|
1151
|
+
cleaned_cells = []
|
1152
|
+
for cell in notebook.cells:
|
1153
|
+
if not hasattr(cell, "cell_type") or not hasattr(cell, "source"):
|
1154
|
+
continue
|
1155
|
+
|
1156
|
+
if (
|
1157
|
+
"## Submission" not in cell.source
|
1158
|
+
and "# Save your notebook first," not in cell.source
|
1159
|
+
):
|
1160
|
+
if cell.cell_type == "markdown":
|
1161
|
+
cell.metadata["editable"] = cell.metadata.get("editable", False)
|
1162
|
+
cell.metadata["deletable"] = cell.metadata.get("deletable", False)
|
1163
|
+
if cell.cell_type == "code":
|
1164
|
+
cell.metadata["tags"] = cell.metadata.get("tags", [])
|
1165
|
+
if "skip-execution" not in cell.metadata["tags"]:
|
1166
|
+
cell.metadata["tags"].append("skip-execution")
|
1167
|
+
|
1168
|
+
cleaned_cells.append(cell)
|
1169
|
+
else:
|
1170
|
+
(f"Removed cell: {cell.source.strip()[:50]}...")
|
1171
|
+
|
1172
|
+
notebook.cells = cleaned_cells
|
1173
|
+
|
1174
|
+
with open(notebook_path, "w", encoding="utf-8") as f:
|
1175
|
+
nbformat.write(notebook, f)
|
1176
|
+
logger.info(f"Cleaned notebook: {notebook_path}")
|
1177
|
+
|
1178
|
+
except Exception as e:
|
1179
|
+
logger.info(f"Error cleaning notebook {notebook_path}: {e}")
|
1180
|
+
|
1181
|
+
|
1182
|
+
def ensure_imports(output_file, header_lines):
|
1183
|
+
"""
|
1184
|
+
Ensures specified header lines are present at the top of the file.
|
1185
|
+
|
1186
|
+
Args:
|
1187
|
+
output_file (str): The path of the file to check and modify.
|
1188
|
+
header_lines (list of str): Lines to ensure are present at the top.
|
1189
|
+
|
1190
|
+
Returns:
|
1191
|
+
str: The existing content of the file (without the header).
|
1192
|
+
"""
|
1193
|
+
existing_content = ""
|
1194
|
+
if os.path.exists(output_file):
|
1195
|
+
with open(output_file, "r", encoding="utf-8") as f:
|
1196
|
+
existing_content = f.read()
|
1197
|
+
|
1198
|
+
# Determine missing lines
|
1199
|
+
missing_lines = [line for line in header_lines if line not in existing_content]
|
1200
|
+
|
1201
|
+
# Write the updated content back to the file
|
1202
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
1203
|
+
# Add missing lines at the top
|
1204
|
+
f.writelines(missing_lines)
|
1205
|
+
# Retain the existing content
|
1206
|
+
f.write(existing_content)
|
1207
|
+
|
1208
|
+
return existing_content
|
1209
|
+
|
1210
|
+
|
1211
|
+
def replace_cells_between_markers(data, markers, ipynb_file, output_file):
|
1212
|
+
"""
|
1213
|
+
Replaces the cells between specified markers in a Jupyter Notebook (.ipynb file)
|
1214
|
+
with provided replacement cells and writes the result to the output file.
|
1215
|
+
|
1216
|
+
Parameters:
|
1217
|
+
data (list): A list of dictionaries with data for creating replacement cells.
|
1218
|
+
markers (tuple): A tuple containing two strings: the BEGIN and END markers.
|
1219
|
+
ipynb_file (str): Path to the input Jupyter Notebook file.
|
1220
|
+
output_file (str): Path to the output Jupyter Notebook file.
|
1221
|
+
|
1222
|
+
Returns:
|
1223
|
+
None: Writes the modified notebook to the output file.
|
1224
|
+
"""
|
1225
|
+
begin_marker, end_marker = markers
|
1226
|
+
file_name_ipynb = ipynb_file.split("/")[-1].replace("_temp.ipynb", "")
|
1227
|
+
|
1228
|
+
file_name_ipynb = sanitize_string(file_name_ipynb)
|
1229
|
+
|
1230
|
+
# Iterate over each set of replacement data
|
1231
|
+
for data_ in data:
|
1232
|
+
dict_ = data_[next(iter(data_.keys()))]
|
1233
|
+
|
1234
|
+
# Create the replacement cells
|
1235
|
+
replacement_cells = {
|
1236
|
+
"cell_type": "code",
|
1237
|
+
"metadata": {},
|
1238
|
+
"source": [
|
1239
|
+
"# Run this block of code by pressing Shift + Enter to display the question\n",
|
1240
|
+
f"from questions.{file_name_ipynb} import Question{dict_['question number']}\n",
|
1241
|
+
f"Question{dict_['question number']}().show()\n",
|
1242
|
+
],
|
1243
|
+
"outputs": [],
|
1244
|
+
"execution_count": None,
|
1245
|
+
}
|
1246
|
+
|
1247
|
+
# Process the notebook cells
|
1248
|
+
new_cells = []
|
1249
|
+
inside_markers = False
|
1250
|
+
done = False
|
1251
|
+
|
1252
|
+
# Load the notebook data
|
1253
|
+
with open(ipynb_file, "r", encoding="utf-8") as f:
|
1254
|
+
notebook_data = json.load(f)
|
1255
|
+
|
1256
|
+
for cell in notebook_data["cells"]:
|
1257
|
+
if cell.get("cell_type") == "raw" and not done:
|
1258
|
+
if any(begin_marker in line for line in cell.get("source", [])):
|
1259
|
+
# Enter the marked block
|
1260
|
+
inside_markers = True
|
1261
|
+
new_cells.append(replacement_cells)
|
1262
|
+
continue
|
1263
|
+
elif inside_markers:
|
1264
|
+
if any(end_marker in line for line in cell.get("source", [])):
|
1265
|
+
# Exit the marked block
|
1266
|
+
inside_markers = False
|
1267
|
+
done = True
|
1268
|
+
continue
|
1269
|
+
else:
|
1270
|
+
continue
|
1271
|
+
else:
|
1272
|
+
new_cells.append(cell)
|
1273
|
+
elif inside_markers:
|
1274
|
+
# Skip cells inside the marked block
|
1275
|
+
continue
|
1276
|
+
else:
|
1277
|
+
new_cells.append(cell)
|
1278
|
+
continue
|
1279
|
+
|
1280
|
+
if done:
|
1281
|
+
# Add cells outside the marked block
|
1282
|
+
new_cells.append(cell)
|
1283
|
+
continue
|
1284
|
+
|
1285
|
+
# Update the notebook with modified cells, preserving metadata
|
1286
|
+
notebook_data["cells"] = new_cells
|
1287
|
+
|
1288
|
+
# Write the modified notebook to the output file
|
1289
|
+
with open(output_file, "w", encoding="utf-8") as f:
|
1290
|
+
json.dump(notebook_data, f, indent=2)
|
1291
|
+
|
1292
|
+
# Update ipynb_file to the output file for subsequent iterations
|
1293
|
+
ipynb_file = output_file
|
1294
|
+
|
1295
|
+
|
1296
|
+
def generate_mcq_file(data_dict, output_file="mc_questions.py"):
|
1297
|
+
"""
|
1298
|
+
Generates a Python file defining an MCQuestion class from a dictionary.
|
1299
|
+
|
1300
|
+
Args:
|
1301
|
+
data_dict (dict): A nested dictionary containing question metadata.
|
1302
|
+
output_file (str): The path for the output Python file.
|
1303
|
+
|
1304
|
+
Returns:
|
1305
|
+
None
|
1306
|
+
"""
|
1307
|
+
|
1308
|
+
# Define header lines
|
1309
|
+
header_lines = [
|
1310
|
+
"from pykubegrader.widgets.multiple_choice import MCQuestion, MCQ\n",
|
1311
|
+
"import pykubegrader.initialize\n",
|
1312
|
+
"import panel as pn\n\n",
|
1313
|
+
"pn.extension()\n\n",
|
1314
|
+
]
|
1315
|
+
|
1316
|
+
# Ensure header lines are present
|
1317
|
+
existing_content = ensure_imports(output_file, header_lines)
|
1318
|
+
|
1319
|
+
for question_dict in data_dict:
|
1320
|
+
with open(output_file, "a", encoding="utf-8") as f:
|
1321
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1322
|
+
if i == 0:
|
1323
|
+
# Write the MCQuestion class
|
1324
|
+
f.write(
|
1325
|
+
f"class Question{q_value['question number']}(MCQuestion):\n"
|
1326
|
+
)
|
1327
|
+
f.write(" def __init__(self):\n")
|
1328
|
+
f.write(" super().__init__(\n")
|
1329
|
+
f.write(f" title=f'{q_value['question_text']}',\n")
|
1330
|
+
f.write(" style=MCQ,\n")
|
1331
|
+
f.write(
|
1332
|
+
f" question_number={q_value['question number']},\n"
|
1333
|
+
)
|
1334
|
+
break
|
1335
|
+
|
1336
|
+
keys = []
|
1337
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1338
|
+
# Write keys
|
1339
|
+
keys.append(f"q{q_value['subquestion_number']}-{q_value['name']}")
|
1340
|
+
|
1341
|
+
f.write(f" keys={keys},\n")
|
1342
|
+
|
1343
|
+
options = []
|
1344
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1345
|
+
# Write options
|
1346
|
+
options.append(q_value["OPTIONS"])
|
1347
|
+
|
1348
|
+
f.write(f" options={options},\n")
|
1349
|
+
|
1350
|
+
descriptions = []
|
1351
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1352
|
+
# Write descriptions
|
1353
|
+
descriptions.append(q_value["question_text"])
|
1354
|
+
f.write(f" descriptions={descriptions},\n")
|
1355
|
+
|
1356
|
+
points = []
|
1357
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1358
|
+
# Write points
|
1359
|
+
points.append(q_value["points"])
|
1360
|
+
|
1361
|
+
f.write(f" points={points},\n")
|
1362
|
+
f.write(" )\n")
|
1363
|
+
|
1364
|
+
|
1365
|
+
def generate_select_many_file(data_dict, output_file="select_many_questions.py"):
|
1366
|
+
"""
|
1367
|
+
Generates a Python file defining an MCQuestion class from a dictionary.
|
1368
|
+
|
1369
|
+
Args:
|
1370
|
+
data_dict (dict): A nested dictionary containing question metadata.
|
1371
|
+
output_file (str): The path for the output Python file.
|
1372
|
+
|
1373
|
+
Returns:
|
1374
|
+
None
|
1375
|
+
"""
|
1376
|
+
|
1377
|
+
# Define header lines
|
1378
|
+
header_lines = [
|
1379
|
+
"from pykubegrader.widgets.select_many import MultiSelect, SelectMany\n",
|
1380
|
+
"import pykubegrader.initialize\n",
|
1381
|
+
"import panel as pn\n\n",
|
1382
|
+
"pn.extension()\n\n",
|
1383
|
+
]
|
1384
|
+
|
1385
|
+
# Ensure header lines are present
|
1386
|
+
existing_content = ensure_imports(output_file, header_lines)
|
1387
|
+
|
1388
|
+
for question_dict in data_dict:
|
1389
|
+
with open(output_file, "a", encoding="utf-8") as f:
|
1390
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1391
|
+
if i == 0:
|
1392
|
+
# Write the MCQuestion class
|
1393
|
+
f.write(
|
1394
|
+
f"class Question{q_value['question number']}(SelectMany):\n"
|
1395
|
+
)
|
1396
|
+
f.write(" def __init__(self):\n")
|
1397
|
+
f.write(" super().__init__(\n")
|
1398
|
+
f.write(f" title=f'{q_value['question_text']}',\n")
|
1399
|
+
f.write(" style=MultiSelect,\n")
|
1400
|
+
f.write(
|
1401
|
+
f" question_number={q_value['question number']},\n"
|
1402
|
+
)
|
1403
|
+
break
|
1404
|
+
|
1405
|
+
keys = []
|
1406
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1407
|
+
# Write keys
|
1408
|
+
keys.append(f"q{q_value['subquestion_number']}-{q_value['name']}")
|
1409
|
+
|
1410
|
+
f.write(f" keys={keys},\n")
|
1411
|
+
|
1412
|
+
descriptions = []
|
1413
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1414
|
+
# Write descriptions
|
1415
|
+
descriptions.append(q_value["question_text"])
|
1416
|
+
f.write(f" descriptions={descriptions},\n")
|
1417
|
+
|
1418
|
+
options = []
|
1419
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1420
|
+
# Write options
|
1421
|
+
options.append(q_value["OPTIONS"])
|
1422
|
+
|
1423
|
+
f.write(f" options={options},\n")
|
1424
|
+
|
1425
|
+
points = []
|
1426
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1427
|
+
# Write points
|
1428
|
+
points.append(q_value["points"])
|
1429
|
+
|
1430
|
+
f.write(f" points={points},\n")
|
1431
|
+
|
1432
|
+
first_key = next(iter(question_dict))
|
1433
|
+
if "grade" in question_dict[first_key]:
|
1434
|
+
grade = question_dict[first_key]["grade"]
|
1435
|
+
f.write(f" grade={grade},\n")
|
1436
|
+
|
1437
|
+
f.write(" )\n")
|
1438
|
+
|
1439
|
+
|
1440
|
+
def generate_tf_file(data_dict, output_file="tf_questions.py"):
|
1441
|
+
"""
|
1442
|
+
Generates a Python file defining an MCQuestion class from a dictionary.
|
1443
|
+
|
1444
|
+
Args:
|
1445
|
+
data_dict (dict): A nested dictionary containing question metadata.
|
1446
|
+
output_file (str): The path for the output Python file.
|
1447
|
+
|
1448
|
+
Returns:
|
1449
|
+
None
|
1450
|
+
"""
|
1451
|
+
|
1452
|
+
# Define header lines
|
1453
|
+
header_lines = [
|
1454
|
+
"from pykubegrader.widgets.true_false import TFQuestion, TrueFalse_style\n",
|
1455
|
+
"import pykubegrader.initialize\n",
|
1456
|
+
"import panel as pn\n\n",
|
1457
|
+
"pn.extension()\n\n",
|
1458
|
+
]
|
1459
|
+
|
1460
|
+
# Ensure header lines are present
|
1461
|
+
existing_content = ensure_imports(output_file, header_lines)
|
1462
|
+
|
1463
|
+
for question_dict in data_dict:
|
1464
|
+
with open(output_file, "a", encoding="utf-8") as f:
|
1465
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1466
|
+
if i == 0:
|
1467
|
+
# Write the MCQuestion class
|
1468
|
+
f.write(
|
1469
|
+
f"class Question{q_value['question number']}(TFQuestion):\n"
|
1470
|
+
)
|
1471
|
+
f.write(" def __init__(self):\n")
|
1472
|
+
f.write(" super().__init__(\n")
|
1473
|
+
f.write(f" title=f'{q_value['question_text']}',\n")
|
1474
|
+
f.write(" style=TrueFalse_style,\n")
|
1475
|
+
f.write(
|
1476
|
+
f" question_number={q_value['question number']},\n"
|
1477
|
+
)
|
1478
|
+
break
|
1479
|
+
|
1480
|
+
keys = []
|
1481
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1482
|
+
# Write keys
|
1483
|
+
keys.append(f"q{q_value['subquestion_number']}-{q_value['name']}")
|
1484
|
+
|
1485
|
+
f.write(f" keys={keys},\n")
|
1486
|
+
|
1487
|
+
descriptions = []
|
1488
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1489
|
+
# Write descriptions
|
1490
|
+
descriptions.append(q_value["question_text"])
|
1491
|
+
f.write(f" descriptions={descriptions},\n")
|
1492
|
+
|
1493
|
+
points = []
|
1494
|
+
for i, (q_key, q_value) in enumerate(question_dict.items()):
|
1495
|
+
# Write points
|
1496
|
+
points.append(q_value["points"])
|
1497
|
+
|
1498
|
+
f.write(f" points={points},\n")
|
1499
|
+
f.write(" )\n")
|
1500
|
+
|
1501
|
+
|
1502
|
+
def sanitize_string(input_string):
|
1503
|
+
"""
|
1504
|
+
Converts a string into a valid Python variable name.
|
1505
|
+
|
1506
|
+
Args:
|
1507
|
+
input_string (str): The string to convert.
|
1508
|
+
|
1509
|
+
Returns:
|
1510
|
+
str: A valid Python variable name.
|
1511
|
+
"""
|
1512
|
+
# Replace invalid characters with underscores
|
1513
|
+
sanitized = re.sub(r"\W|^(?=\d)", "_", input_string)
|
1514
|
+
return sanitized
|
1515
|
+
|
1516
|
+
|
1517
|
+
def main():
|
1518
|
+
parser = argparse.ArgumentParser(
|
1519
|
+
description="Recursively process Jupyter notebooks with '# ASSIGNMENT CONFIG', move them to a solutions folder, and run otter assign."
|
1520
|
+
)
|
1521
|
+
parser.add_argument(
|
1522
|
+
"root_folder", type=str, help="Path to the root folder to process"
|
1523
|
+
)
|
1524
|
+
args = parser.parse_args()
|
1525
|
+
|
1526
|
+
processor = NotebookProcessor(args.root_folder)
|
1527
|
+
processor.process_notebooks()
|
1528
|
+
|
1529
|
+
|
1530
|
+
if __name__ == "__main__":
|
1531
|
+
sys.exit(main())
|