PyKubeGrader 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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())