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.
@@ -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())