PyKubeGrader 0.1.8__tar.gz → 0.1.10__tar.gz
Sign up to get free protection for your applications and to get access to all the features.
- {pykubegrader-0.1.8/src/PyKubeGrader.egg-info → pykubegrader-0.1.10}/PKG-INFO +1 -1
- {pykubegrader-0.1.8 → pykubegrader-0.1.10/src/PyKubeGrader.egg-info}/PKG-INFO +1 -1
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/SOURCES.txt +1 -0
- pykubegrader-0.1.10/src/pykubegrader/build/api_notebook_builder.py +492 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/build/build_folder.py +207 -81
- pykubegrader-0.1.10/src/pykubegrader/initialize.py +68 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/telemetry.py +26 -6
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/validate.py +88 -76
- pykubegrader-0.1.8/src/pykubegrader/initialize.py +0 -42
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/.coveragerc +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/.github/workflows/main.yml +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/.gitignore +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/.readthedocs.yml +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/AUTHORS.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/CHANGELOG.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/CONTRIBUTING.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/LICENSE.txt +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/README.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/Makefile +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/_static/Drexel_blue_Logo_square_Dark.png +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/_static/Drexel_blue_Logo_square_Light.png +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/_static/custom.css +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/authors.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/changelog.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/conf.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/contributing.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/index.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/license.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/readme.rst +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/docs/requirements.txt +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/examples/.responses.json +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/examples/true_false.ipynb +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/pyproject.toml +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/setup.cfg +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/setup.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/dependency_links.txt +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/entry_points.txt +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/not-zip-safe +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/requires.txt +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/PyKubeGrader.egg-info/top_level.txt +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/__init__.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/utils.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/__init__.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/multiple_choice.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/reading_question.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/select_many.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/student_info.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/style.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/true_false.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets/types_question.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/__init__.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/multi_select.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/reading.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/src/pykubegrader/widgets_base/select.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/tests/conftest.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/tests/import_test.py +0 -0
- {pykubegrader-0.1.8 → pykubegrader-0.1.10}/tox.ini +0 -0
@@ -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
|