PyKubeGrader 0.1.9__tar.gz → 0.1.10__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. {pykubegrader-0.1.9/src/PyKubeGrader.egg-info → pykubegrader-0.1.10}/PKG-INFO +1 -1
  2. {pykubegrader-0.1.9 → pykubegrader-0.1.10/src/PyKubeGrader.egg-info}/PKG-INFO +1 -1
  3. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/SOURCES.txt +1 -0
  4. pykubegrader-0.1.10/src/pykubegrader/build/api_notebook_builder.py +492 -0
  5. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/build/build_folder.py +207 -81
  6. pykubegrader-0.1.10/src/pykubegrader/initialize.py +68 -0
  7. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/telemetry.py +26 -6
  8. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/validate.py +88 -76
  9. pykubegrader-0.1.9/src/pykubegrader/initialize.py +0 -41
  10. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/.coveragerc +0 -0
  11. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/.github/workflows/main.yml +0 -0
  12. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/.gitignore +0 -0
  13. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/.readthedocs.yml +0 -0
  14. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/AUTHORS.rst +0 -0
  15. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/CHANGELOG.rst +0 -0
  16. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/CONTRIBUTING.rst +0 -0
  17. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/LICENSE.txt +0 -0
  18. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/README.rst +0 -0
  19. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/Makefile +0 -0
  20. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/_static/Drexel_blue_Logo_square_Dark.png +0 -0
  21. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/_static/Drexel_blue_Logo_square_Light.png +0 -0
  22. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/_static/custom.css +0 -0
  23. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/authors.rst +0 -0
  24. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/changelog.rst +0 -0
  25. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/conf.py +0 -0
  26. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/contributing.rst +0 -0
  27. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/index.rst +0 -0
  28. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/license.rst +0 -0
  29. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/readme.rst +0 -0
  30. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/docs/requirements.txt +0 -0
  31. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/examples/.responses.json +0 -0
  32. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/examples/true_false.ipynb +0 -0
  33. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/pyproject.toml +0 -0
  34. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/setup.cfg +0 -0
  35. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/setup.py +0 -0
  36. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/dependency_links.txt +0 -0
  37. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/entry_points.txt +0 -0
  38. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/not-zip-safe +0 -0
  39. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/requires.txt +0 -0
  40. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/top_level.txt +0 -0
  41. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/__init__.py +0 -0
  42. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/utils.py +0 -0
  43. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/__init__.py +0 -0
  44. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/multiple_choice.py +0 -0
  45. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/reading_question.py +0 -0
  46. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/select_many.py +0 -0
  47. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/student_info.py +0 -0
  48. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/style.py +0 -0
  49. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/true_false.py +0 -0
  50. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets/types_question.py +0 -0
  51. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/__init__.py +0 -0
  52. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/multi_select.py +0 -0
  53. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/reading.py +0 -0
  54. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/select.py +0 -0
  55. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/tests/conftest.py +0 -0
  56. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/tests/import_test.py +0 -0
  57. {pykubegrader-0.1.9 → pykubegrader-0.1.10}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyKubeGrader
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Add a short description here!
5
5
  Home-page: https://github.com/pyscaffold/pyscaffold/
6
6
  Author: jagar2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: PyKubeGrader
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Add a short description here!
5
5
  Home-page: https://github.com/pyscaffold/pyscaffold/
6
6
  Author: jagar2
@@ -37,6 +37,7 @@ src/pykubegrader/initialize.py
37
37
  src/pykubegrader/telemetry.py
38
38
  src/pykubegrader/utils.py
39
39
  src/pykubegrader/validate.py
40
+ src/pykubegrader/build/api_notebook_builder.py
40
41
  src/pykubegrader/build/build_folder.py
41
42
  src/pykubegrader/widgets/__init__.py
42
43
  src/pykubegrader/widgets/multiple_choice.py
@@ -0,0 +1,492 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ import json
5
+ import nbformat
6
+ import json
7
+ import re
8
+ import shutil
9
+
10
+
11
+ @dataclass
12
+ class FastAPINotebookBuilder:
13
+ notebook_path: str
14
+ temp_notebook: Optional[str] = None
15
+
16
+ def __post_init__(self):
17
+ self.root_path, self.filename = FastAPINotebookBuilder.get_filename_and_root(
18
+ self.notebook_path
19
+ )
20
+ self.run()
21
+
22
+ def run(self):
23
+
24
+ # here for easy debugging
25
+ if self.temp_notebook is not None:
26
+ shutil.copy(
27
+ self.notebook_path, self.notebook_path.replace(".ipynb", "_temp.ipynb")
28
+ )
29
+ self.temp_notebook = self.notebook_path.replace(".ipynb", "_temp.ipynb")
30
+ else:
31
+ self.temp_notebook = self.notebook_path
32
+
33
+ self.assertion_tests_dict = self.question_dict()
34
+ self.add_api_code()
35
+
36
+ def add_api_code(self):
37
+
38
+ for i, (cell_index, cell_dict) in enumerate(self.assertion_tests_dict.items()):
39
+ print(
40
+ f"Processing cell {cell_index + 1}, {i} of {len(self.assertion_tests_dict)}"
41
+ )
42
+
43
+ cell = self.get_cell(cell_index)
44
+ cell_source = FastAPINotebookBuilder.add_import_statements_to_tests(
45
+ cell["source"]
46
+ )
47
+
48
+ last_import_line_ind = FastAPINotebookBuilder.find_last_import_line(
49
+ cell_source
50
+ )
51
+
52
+ # header, body = FastAPINotebookBuilder.split_list_at_marker(cell_source)
53
+
54
+ updated_cell_source = []
55
+ updated_cell_source.extend(cell_source[: last_import_line_ind + 1])
56
+ if cell_dict["is_first"]:
57
+ updated_cell_source.extend(
58
+ self.construct_first_cell_question_header(cell_dict)
59
+ )
60
+ updated_cell_source.extend(["\n"])
61
+ updated_cell_source.extend(
62
+ FastAPINotebookBuilder.construct_question_info(cell_dict)
63
+ )
64
+ updated_cell_source.extend(
65
+ FastAPINotebookBuilder.construct_update_responses(cell_dict)
66
+ )
67
+
68
+ updated_cell_source.extend(cell_source[last_import_line_ind + 1 :])
69
+ updated_cell_source.extend(["\n"])
70
+
71
+ updated_cell_source.extend(
72
+ FastAPINotebookBuilder.construct_graders(cell_dict)
73
+ )
74
+ updated_cell_source.extend(["\n"])
75
+ updated_cell_source.extend(
76
+ ["earned_points = float(os.environ.get('EARNED_POINTS', 0))\n"]
77
+ )
78
+ updated_cell_source.extend(["earned_points += score\n"])
79
+ updated_cell_source.extend(
80
+ [f'log_variable(f"{{score}}, {{max_score}}", question_id)\n']
81
+ )
82
+ updated_cell_source.extend(
83
+ ["os.environ['EARNED_POINTS'] = str(earned_points)\n"]
84
+ )
85
+
86
+ # cell_source = FastAPINotebookBuilder.insert_list_at_index(
87
+ # cell_source, updated_cell_source, last_import_line_ind + 1
88
+ # )
89
+
90
+ self.replace_cell_source(cell_index, updated_cell_source)
91
+
92
+ def construct_first_cell_question_header(self, cell_dict):
93
+ max_question_points = sum(
94
+ cell["points"]
95
+ for cell in self.assertion_tests_dict.values()
96
+ if cell["question"] == cell_dict["question"]
97
+ )
98
+
99
+ first_cell_header = ["max_question_points = " + str(max_question_points) + "\n"]
100
+ first_cell_header.append("earned_points = 0 \n")
101
+ first_cell_header.append("os.environ['EARNED_POINTS'] = str(earned_points)\n")
102
+
103
+ return first_cell_header
104
+
105
+ @staticmethod
106
+ def construct_update_responses(cell_dict):
107
+ update_responses = []
108
+ question_id = cell_dict["question"] + "-" + str(cell_dict["test_number"]) + "\n"
109
+
110
+ logging_variables = cell_dict["logging_variables"]
111
+
112
+ for logging_variable in logging_variables:
113
+ update_responses.append(
114
+ f"responses = update_responses(question_id, {logging_variable})\n"
115
+ )
116
+
117
+ return update_responses
118
+
119
+ @staticmethod
120
+ def split_list_at_marker(input_list, marker="""# END TEST CONFIG"""):
121
+ """
122
+ Splits a list into two parts at the specified marker string.
123
+
124
+ Args:
125
+ input_list (list): The list to split.
126
+ marker (str): The string at which to split the list.
127
+
128
+ Returns:
129
+ tuple: A tuple containing two lists. The first list contains the elements
130
+ before the marker, and the second list contains the elements after
131
+ the marker (excluding the marker itself).
132
+ """
133
+ if marker in input_list:
134
+ index = input_list.index(marker)
135
+ return input_list[: index + 1], input_list[index + 2 :]
136
+ else:
137
+ return (
138
+ input_list,
139
+ [],
140
+ ) # If the marker is not in the list, return the original list and an empty list
141
+
142
+ @staticmethod
143
+ def construct_graders(cell_dict):
144
+
145
+ # Generate Python code
146
+ added_code = [
147
+ "if "
148
+ + " and ".join(f"({test})" for test in cell_dict["assertions"])
149
+ + ":\n"
150
+ ]
151
+ added_code.append(f" score = {cell_dict['points']}\n")
152
+
153
+ return added_code
154
+
155
+ @staticmethod
156
+ def construct_question_info(cell_dict):
157
+ question_info = []
158
+
159
+ question_id = cell_dict["question"] + "-" + str(cell_dict["test_number"])
160
+
161
+ question_info.append(f'question_id = "{question_id}"' + "\n")
162
+ question_info.append(f'max_score = {cell_dict["points"]}\n')
163
+ question_info.append("score = 0\n")
164
+
165
+ return question_info
166
+
167
+ @staticmethod
168
+ def insert_list_at_index(
169
+ original_list, insert_list, index, line_break=True, inplace_line_break=True
170
+ ):
171
+ """
172
+ Inserts a list into another list at a specific index.
173
+
174
+ Args:
175
+ original_list (list): The original list.
176
+ insert_list (list): The list to insert.
177
+ index (int): The position at which to insert the new list.
178
+
179
+ Returns:
180
+ list: A single combined list with the second list inserted at the specified index.
181
+ """
182
+
183
+ if inplace_line_break:
184
+ insert_list = [s + "\n" for s in insert_list]
185
+
186
+ if line_break:
187
+ if inplace_line_break:
188
+ insert_list = ["\n"] + insert_list
189
+ else:
190
+ insert_list = ["\n"] + insert_list + ["\n"]
191
+
192
+ return original_list[:index] + insert_list + original_list[index:]
193
+
194
+ @staticmethod
195
+ def add_import_statements_to_tests(cell_source):
196
+ """
197
+ Adds the necessary import statements to the first cell of the notebook.
198
+ """
199
+
200
+ end_test_config_line = "# END TEST CONFIG"
201
+
202
+ # Imports to add
203
+ imports = [
204
+ "from pykubegrader.telemetry import (\n",
205
+ " ensure_responses,\n",
206
+ " log_variable,\n",
207
+ " score_question,\n",
208
+ " submit_question_new,\n",
209
+ " telemetry,\n",
210
+ " update_responses,\n",
211
+ ")\n",
212
+ "import os\n",
213
+ ]
214
+
215
+ for i, line in enumerate(cell_source):
216
+ if end_test_config_line in line:
217
+ # Insert the imports immediately after the current line
218
+ cell_source[i + 1 : i + 1] = [
219
+ "\n"
220
+ ] + imports # Add a blank line for readability
221
+ return cell_source # Exit the loop once the imports are inserted
222
+
223
+ def extract_first_cell(self):
224
+ with open(self.temp_notebook, "r", encoding="utf-8") as f:
225
+ notebook = json.load(f)
226
+ if "cells" in notebook and len(notebook["cells"]) > 0:
227
+ return notebook["cells"][0]
228
+ else:
229
+ return None
230
+
231
+ @staticmethod
232
+ def get_filename_and_root(path):
233
+ path_obj = Path(path).resolve() # Resolve the path to get an absolute path
234
+ root_path = path_obj.parent # Get the parent directory
235
+ filename = path_obj.name # Get the filename
236
+ return root_path, filename
237
+
238
+ def get_cell(self, cell_index):
239
+ with open(self.temp_notebook, "r", encoding="utf-8") as f:
240
+ notebook = json.load(f)
241
+ if "cells" in notebook and len(notebook["cells"]) > cell_index:
242
+ return notebook["cells"][cell_index]
243
+ else:
244
+ return None
245
+
246
+ def replace_cell_source(self, cell_index, new_source):
247
+ """
248
+ Replace the source code of a specific Jupyter notebook cell.
249
+
250
+ Args:
251
+ cell_index (int): Index of the cell to be modified (0-based).
252
+ new_source (str): New source code to replace the cell's content.
253
+ """
254
+ # Load the notebook
255
+ with open(self.temp_notebook, "r", encoding="utf-8") as f:
256
+ notebook = nbformat.read(f, as_version=4)
257
+
258
+ # Check if the cell index is valid
259
+ if cell_index >= len(notebook.cells) or cell_index < 0:
260
+ raise IndexError(
261
+ f"Cell index {cell_index} is out of range for this notebook."
262
+ )
263
+
264
+ # Replace the source code of the specified cell
265
+ notebook.cells[cell_index]["source"] = new_source
266
+
267
+ # Save the notebook
268
+ with open(self.temp_notebook, "w", encoding="utf-8") as f:
269
+ nbformat.write(notebook, f)
270
+ print(f"Updated notebook saved to {self.temp_notebook}")
271
+
272
+ @staticmethod
273
+ def find_last_import_line(cell_source):
274
+ """
275
+ Finds the index of the last line with an import statement in a list of code lines,
276
+ including multiline import statements.
277
+
278
+ Args:
279
+ cell_source (list): List of strings representing the code lines.
280
+
281
+ Returns:
282
+ int: The index of the last line with an import statement, or -1 if no import is found.
283
+ """
284
+ last_import_index = -1
285
+ is_multiline_import = False # Flag to track if we're inside a multiline import
286
+
287
+ for i, line in enumerate(cell_source):
288
+ stripped_line = line.strip()
289
+
290
+ if is_multiline_import:
291
+ # Continue tracking multiline import
292
+ if stripped_line.endswith("\\") or (
293
+ stripped_line and not stripped_line.endswith(")")
294
+ ):
295
+ last_import_index = i # Update to current line
296
+ continue
297
+ else:
298
+ is_multiline_import = False # End of multiline import
299
+ last_import_index = i # Update to current line
300
+
301
+ # Check for single-line or start of multiline imports
302
+ if stripped_line.startswith("import") or stripped_line.startswith("from"):
303
+ last_import_index = i
304
+ # Check if it's a multiline import
305
+ if stripped_line.endswith("\\") or "(" in stripped_line:
306
+ is_multiline_import = True
307
+
308
+ return last_import_index
309
+
310
+ @staticmethod
311
+ def extract_log_variables(cell):
312
+ """Extracts log variables from the first cell."""
313
+ if "source" in cell:
314
+ for line in cell["source"]:
315
+ # Look for the log_variables pattern
316
+ match = re.search(r"log_variables:\s*\[(.*?)\]", line)
317
+ if match:
318
+ # Split the variables by comma and strip whitespace
319
+ log_variables = [var.strip() for var in match.group(1).split(",")]
320
+ return log_variables
321
+ return []
322
+
323
+ def tag_questions(cells_dict):
324
+ """
325
+ Adds 'is_first' and 'is_last' boolean flags to the cells based on their position
326
+ within the group of the same question. All cells will have both flags.
327
+
328
+ Args:
329
+ cells_dict (dict): A dictionary where keys are cell IDs and values are cell details.
330
+
331
+ Returns:
332
+ dict: The modified dictionary with 'is_first' and 'is_last' flags added.
333
+ """
334
+ if not isinstance(cells_dict, dict):
335
+ raise ValueError("Input must be a dictionary.")
336
+
337
+ # Ensure all cells have the expected structure
338
+ for key, cell in cells_dict.items():
339
+ if not isinstance(cell, dict):
340
+ raise ValueError(f"Cell {key} is not a dictionary.")
341
+ if "question" not in cell:
342
+ raise KeyError(f"Cell {key} is missing the 'question' key.")
343
+
344
+ # Group the keys by question name
345
+ question_groups = {}
346
+ for key, cell in cells_dict.items():
347
+ question = cell.get(
348
+ "question"
349
+ ) # Use .get() to avoid errors if key is missing
350
+ if question not in question_groups:
351
+ question_groups[question] = []
352
+ question_groups[question].append(key)
353
+
354
+ # Add 'is_first' and 'is_last' flags to all cells
355
+ for question, keys in question_groups.items():
356
+ test_number = 1
357
+ for i, key in enumerate(keys):
358
+ cells_dict[key]["is_first"] = i == 0
359
+ cells_dict[key]["is_last"] = i == len(keys) - 1
360
+ cells_dict[key]["test_number"] = test_number
361
+ test_number += 1
362
+
363
+ return cells_dict
364
+
365
+ def question_dict(self):
366
+
367
+ notebook_path = Path(self.temp_notebook)
368
+ if not notebook_path.exists():
369
+ raise FileNotFoundError(f"The file {notebook_path} does not exist.")
370
+
371
+ with open(notebook_path, "r", encoding="utf-8") as f:
372
+ notebook = json.load(f)
373
+
374
+ results_dict = {}
375
+
376
+ for cell_index, cell in enumerate(notebook.get("cells", [])):
377
+ if cell.get("cell_type") == "raw":
378
+ source = "".join(cell.get("source", ""))
379
+ if source.strip().startswith("# BEGIN QUESTION"):
380
+ question_name = re.search(r"name:\s*(.*)", source)
381
+ question_name = (
382
+ question_name.group(1).strip() if question_name else None
383
+ )
384
+
385
+ elif cell.get("cell_type") == "code":
386
+ source = "".join(cell.get("source", ""))
387
+
388
+ if source.strip().startswith('""" # BEGIN TEST CONFIG'):
389
+ logging_variables = FastAPINotebookBuilder.extract_log_variables(
390
+ cell
391
+ )
392
+
393
+ # Extract assert statements using a more robust approach
394
+ assertions = []
395
+ comments = []
396
+
397
+ # Split the source into lines for processing
398
+ lines = source.split("\n")
399
+ i = 0
400
+ while i < len(lines):
401
+ line = lines[i].strip()
402
+ if line.startswith("assert"):
403
+ # Initialize assertion collection
404
+ assertion_lines = []
405
+ comment = None
406
+
407
+ # Handle the first line
408
+ first_line = line[6:].strip() # Remove 'assert' keyword
409
+ assertion_lines.append(first_line)
410
+
411
+ # Stack to track parentheses
412
+ paren_stack = []
413
+ for char in first_line:
414
+ if char == "(":
415
+ paren_stack.append(char)
416
+ elif char == ")":
417
+ if paren_stack:
418
+ paren_stack.pop()
419
+
420
+ # Continue collecting lines while we have unclosed parentheses
421
+ current_line = i + 1
422
+ while paren_stack and current_line < len(lines):
423
+ next_line = lines[current_line].strip()
424
+ assertion_lines.append(next_line)
425
+
426
+ for char in next_line:
427
+ if char == "(":
428
+ paren_stack.append(char)
429
+ elif char == ")":
430
+ if paren_stack:
431
+ paren_stack.pop()
432
+
433
+ current_line += 1
434
+
435
+ # Join the assertion lines and clean up
436
+ full_assertion = " ".join(assertion_lines)
437
+
438
+ # Extract the comment if it exists (handling both f-strings and regular strings)
439
+ comment_match = re.search(
440
+ r',\s*(?:f?["\'])(.*?)(?:["\'])\s*(?:\)|$)',
441
+ full_assertion,
442
+ )
443
+ if comment_match:
444
+ comment = comment_match.group(1).strip()
445
+ # Remove the comment from the assertion
446
+ full_assertion = full_assertion[
447
+ : comment_match.start()
448
+ ].strip()
449
+
450
+ # Ensure proper parentheses closure
451
+ open_count = full_assertion.count("(")
452
+ close_count = full_assertion.count(")")
453
+ if open_count > close_count:
454
+ full_assertion += ")" * (open_count - close_count)
455
+
456
+ # Clean up the assertion
457
+ if full_assertion.startswith(
458
+ "("
459
+ ) and not full_assertion.endswith(")"):
460
+ full_assertion += ")"
461
+
462
+ assertions.append(full_assertion)
463
+ comments.append(comment)
464
+
465
+ # Update the line counter
466
+ i = current_line
467
+ else:
468
+ i += 1
469
+
470
+ # Extract points value
471
+ points_line = next(
472
+ (line for line in source.split("\n") if "points:" in line), None
473
+ )
474
+ points_value = None
475
+ if points_line:
476
+ try:
477
+ points_value = float(points_line.split(":")[-1].strip())
478
+ except ValueError:
479
+ points_value = None
480
+
481
+ # Add to results dictionary
482
+ results_dict[cell_index] = {
483
+ "assertions": assertions,
484
+ "comments": comments,
485
+ "question": question_name,
486
+ "points": points_value,
487
+ "logging_variables": logging_variables,
488
+ }
489
+
490
+ results_dict = FastAPINotebookBuilder.tag_questions(results_dict)
491
+
492
+ return results_dict