PyKubeGrader 0.3.3__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {pykubegrader-0.3.3/src/PyKubeGrader.egg-info → pykubegrader-0.3.5}/PKG-INFO +1 -4
  2. pykubegrader-0.3.5/ruff.toml +2 -0
  3. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/setup.cfg +1 -3
  4. {pykubegrader-0.3.3 → pykubegrader-0.3.5/src/PyKubeGrader.egg-info}/PKG-INFO +1 -4
  5. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/PyKubeGrader.egg-info/SOURCES.txt +2 -0
  6. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/PyKubeGrader.egg-info/entry_points.txt +1 -0
  7. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/PyKubeGrader.egg-info/requires.txt +0 -3
  8. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/build/api_notebook_builder.py +86 -24
  9. pykubegrader-0.3.5/src/pykubegrader/build/collate.py +190 -0
  10. pykubegrader-0.3.5/src/pykubegrader/grade_reports/grade_reports.py +76 -0
  11. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/grading_tester.ipynb +47 -36
  12. pykubegrader-0.3.5/src/pykubegrader/submit/submit_assignment.py +88 -0
  13. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/telemetry.py +146 -1
  14. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets_base/multi_select.py +2 -1
  15. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets_base/select.py +2 -1
  16. pykubegrader-0.3.5/test.ipynb +50 -0
  17. pykubegrader-0.3.3/ruff.toml +0 -2
  18. pykubegrader-0.3.3/src/pykubegrader/grade_reports/grade_reports.py +0 -171
  19. pykubegrader-0.3.3/src/pykubegrader/submit/submit_assignment.py +0 -104
  20. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/.coveragerc +0 -0
  21. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/.github/workflows/main.yml +0 -0
  22. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/.gitignore +0 -0
  23. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/.readthedocs.yml +0 -0
  24. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/AUTHORS.rst +0 -0
  25. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/CHANGELOG.rst +0 -0
  26. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/CONTRIBUTING.rst +0 -0
  27. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/LICENSE.txt +0 -0
  28. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/README.rst +0 -0
  29. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/Makefile +0 -0
  30. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/_static/Drexel_blue_Logo_square_Dark.png +0 -0
  31. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/_static/Drexel_blue_Logo_square_Light.png +0 -0
  32. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/_static/custom.css +0 -0
  33. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/authors.rst +0 -0
  34. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/changelog.rst +0 -0
  35. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/conf.py +0 -0
  36. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/contributing.rst +0 -0
  37. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/index.rst +0 -0
  38. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/license.rst +0 -0
  39. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/readme.rst +0 -0
  40. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/docs/requirements.txt +0 -0
  41. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/examples/.responses.json +0 -0
  42. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/examples/true_false.ipynb +0 -0
  43. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/pyproject.toml +0 -0
  44. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/setup.py +0 -0
  45. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/PyKubeGrader.egg-info/dependency_links.txt +0 -0
  46. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/PyKubeGrader.egg-info/not-zip-safe +0 -0
  47. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/PyKubeGrader.egg-info/top_level.txt +0 -0
  48. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/__init__.py +0 -0
  49. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/build/__init__.py +0 -0
  50. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/build/build_folder.py +0 -0
  51. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/build/clean_folder.py +0 -0
  52. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/build/markdown_questions.py +0 -0
  53. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/graders/__init__.py +0 -0
  54. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/graders/late_assignments.py +0 -0
  55. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/initialize.py +0 -0
  56. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/log_parser/__init__.py +0 -0
  57. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/log_parser/parse.ipynb +0 -0
  58. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/log_parser/parse.py +0 -0
  59. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/tokens/tokens.py +0 -0
  60. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/tokens/validate_token.py +0 -0
  61. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/utils.py +0 -0
  62. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/validate.py +0 -0
  63. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/__init__.py +0 -0
  64. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/multiple_choice.py +0 -0
  65. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/question_processor.py +0 -0
  66. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/reading_question.py +0 -0
  67. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/select_many.py +0 -0
  68. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/student_info.py +0 -0
  69. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/style.py +0 -0
  70. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/true_false.py +0 -0
  71. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets/types_question.py +0 -0
  72. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets_base/__init__.py +0 -0
  73. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/src/pykubegrader/widgets_base/reading.py +0 -0
  74. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/tests/conftest.py +0 -0
  75. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/tests/import_test.py +0 -0
  76. {pykubegrader-0.3.3 → pykubegrader-0.3.5}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: PyKubeGrader
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Add a short description here!
5
5
  Home-page: https://github.com/pyscaffold/pyscaffold/
6
6
  Author: jagar2
@@ -12,12 +12,10 @@ Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Programming Language :: Python
13
13
  Description-Content-Type: text/x-rst; charset=UTF-8
14
14
  License-File: LICENSE.txt
15
- Requires-Dist: httpx
16
15
  Requires-Dist: importlib-metadata; python_version < "3.8"
17
16
  Requires-Dist: ipython
18
17
  Requires-Dist: mypy
19
18
  Requires-Dist: nbformat
20
- Requires-Dist: nest_asyncio
21
19
  Requires-Dist: numpy
22
20
  Requires-Dist: pandas-stubs
23
21
  Requires-Dist: panel
@@ -28,7 +26,6 @@ Requires-Dist: ruff
28
26
  Requires-Dist: setuptools
29
27
  Requires-Dist: sphinx
30
28
  Requires-Dist: types-python-dateutil
31
- Requires-Dist: types-pyyaml
32
29
  Requires-Dist: types-requests
33
30
  Requires-Dist: types-setuptools
34
31
  Provides-Extra: testing
@@ -0,0 +1,2 @@
1
+ [lint.per-file-ignores]
2
+ "*.ipynb" = ["F811", "F821", "F841"]
@@ -22,12 +22,10 @@ include_package_data = True
22
22
  package_dir =
23
23
  =src
24
24
  install_requires =
25
- httpx
26
25
  importlib-metadata; python_version<"3.8"
27
26
  ipython
28
27
  mypy
29
28
  nbformat
30
- nest_asyncio
31
29
  numpy
32
30
  pandas-stubs
33
31
  panel
@@ -38,7 +36,6 @@ install_requires =
38
36
  setuptools
39
37
  sphinx
40
38
  types-python-dateutil
41
- types-pyyaml
42
39
  types-requests
43
40
  types-setuptools
44
41
 
@@ -59,6 +56,7 @@ console_scripts =
59
56
  otter-folder-builder = pykubegrader.build.build_folder:main
60
57
  otter-folder-cleaner = pykubegrader.build.clean_folder:main
61
58
  markdown-question = pykubegrader.build.markdown_questions:main
59
+ collate-questions = pykubegrader.build.collate:main
62
60
 
63
61
  [tool:pytest]
64
62
  addopts =
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: PyKubeGrader
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Add a short description here!
5
5
  Home-page: https://github.com/pyscaffold/pyscaffold/
6
6
  Author: jagar2
@@ -12,12 +12,10 @@ Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Programming Language :: Python
13
13
  Description-Content-Type: text/x-rst; charset=UTF-8
14
14
  License-File: LICENSE.txt
15
- Requires-Dist: httpx
16
15
  Requires-Dist: importlib-metadata; python_version < "3.8"
17
16
  Requires-Dist: ipython
18
17
  Requires-Dist: mypy
19
18
  Requires-Dist: nbformat
20
- Requires-Dist: nest_asyncio
21
19
  Requires-Dist: numpy
22
20
  Requires-Dist: pandas-stubs
23
21
  Requires-Dist: panel
@@ -28,7 +26,6 @@ Requires-Dist: ruff
28
26
  Requires-Dist: setuptools
29
27
  Requires-Dist: sphinx
30
28
  Requires-Dist: types-python-dateutil
31
- Requires-Dist: types-pyyaml
32
29
  Requires-Dist: types-requests
33
30
  Requires-Dist: types-setuptools
34
31
  Provides-Extra: testing
@@ -10,6 +10,7 @@ pyproject.toml
10
10
  ruff.toml
11
11
  setup.cfg
12
12
  setup.py
13
+ test.ipynb
13
14
  tox.ini
14
15
  .github/workflows/main.yml
15
16
  docs/Makefile
@@ -43,6 +44,7 @@ src/pykubegrader/build/__init__.py
43
44
  src/pykubegrader/build/api_notebook_builder.py
44
45
  src/pykubegrader/build/build_folder.py
45
46
  src/pykubegrader/build/clean_folder.py
47
+ src/pykubegrader/build/collate.py
46
48
  src/pykubegrader/build/markdown_questions.py
47
49
  src/pykubegrader/grade_reports/grade_reports.py
48
50
  src/pykubegrader/graders/__init__.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ collate-questions = pykubegrader.build.collate:main
2
3
  markdown-question = pykubegrader.build.markdown_questions:main
3
4
  otter-folder-builder = pykubegrader.build.build_folder:main
4
5
  otter-folder-cleaner = pykubegrader.build.clean_folder:main
@@ -1,8 +1,6 @@
1
- httpx
2
1
  ipython
3
2
  mypy
4
3
  nbformat
5
- nest_asyncio
6
4
  numpy
7
5
  pandas-stubs
8
6
  panel
@@ -13,7 +11,6 @@ ruff
13
11
  setuptools
14
12
  sphinx
15
13
  types-python-dateutil
16
- types-pyyaml
17
14
  types-requests
18
15
  types-setuptools
19
16
 
@@ -5,6 +5,8 @@ import shutil
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
  from typing import Optional
8
+ import base64
9
+ from typing import Any, Optional
8
10
 
9
11
  import nbformat
10
12
 
@@ -16,14 +18,14 @@ class FastAPINotebookBuilder:
16
18
  assignment_tag: Optional[str] = ""
17
19
  require_key: Optional[bool] = False
18
20
 
19
- def __post_init__(self):
21
+ def __post_init__(self) -> None:
20
22
  self.root_path, self.filename = FastAPINotebookBuilder.get_filename_and_root(
21
23
  self.notebook_path
22
24
  )
23
25
  self.total_points = 0
24
26
  self.run()
25
27
 
26
- def run(self):
28
+ def run(self) -> None:
27
29
  # here for easy debugging
28
30
  if self.temp_notebook is not None:
29
31
  shutil.copy(
@@ -36,7 +38,46 @@ class FastAPINotebookBuilder:
36
38
  self.assertion_tests_dict = self.question_dict()
37
39
  self.add_api_code()
38
40
 
41
+ @staticmethod
42
+ def conceal_tests(cell_source):
43
+ """
44
+ Takes a list of code lines, detects blocks between `# BEGIN HIDE` and `# END HIDE`,
45
+ encodes them in Base64, and replaces them with an `exec()` statement.
46
+
47
+ Returns a new list of lines with the concealed blocks.
48
+ """
49
+
50
+ concealed_lines = []
51
+ hide_mode = False
52
+ hidden_code = []
53
+
54
+ for line in cell_source:
55
+ if "# BEGIN HIDE" in line:
56
+ hide_mode = True
57
+ hidden_code = [] # Start a new hidden block
58
+ concealed_lines.append(line) # Keep the marker for clarity
59
+ continue
60
+ elif "# END HIDE" in line:
61
+ hide_mode = False
62
+ # Encode the entire block
63
+ encoded_block = base64.b64encode(
64
+ "\n".join(hidden_code).encode()
65
+ ).decode()
66
+ concealed_lines.append(
67
+ f'exec(base64.b64decode("{encoded_block}").decode()) # Obfuscated\n'
68
+ )
69
+ concealed_lines.append(line) # Keep the marker for clarity
70
+ continue
71
+
72
+ if hide_mode:
73
+ hidden_code.append(line.strip()) # Collect hidden code
74
+ else:
75
+ concealed_lines.append(line)
76
+
77
+ return concealed_lines
78
+
39
79
  def add_api_code(self):
80
+ def add_api_code(self) -> None:
40
81
  self.compute_max_points_free_response()
41
82
 
42
83
  for i, (cell_index, cell_dict) in enumerate(self.assertion_tests_dict.items()):
@@ -48,6 +89,8 @@ class FastAPINotebookBuilder:
48
89
  cell_source = FastAPINotebookBuilder.add_import_statements_to_tests(
49
90
  cell["source"]
50
91
  )
92
+
93
+ cell_source = FastAPINotebookBuilder.conceal_tests(cell_source)
51
94
 
52
95
  last_import_line_ind = FastAPINotebookBuilder.find_last_import_line(
53
96
  cell_source
@@ -94,7 +137,7 @@ class FastAPINotebookBuilder:
94
137
 
95
138
  self.replace_cell_source(cell_index, updated_cell_source)
96
139
 
97
- def compute_max_points_free_response(self):
140
+ def compute_max_points_free_response(self) -> None:
98
141
  for cell_dict in self.assertion_tests_dict.values():
99
142
  # gets the question name from the first cell to not double count
100
143
  if cell_dict["is_first"]:
@@ -107,7 +150,7 @@ class FastAPINotebookBuilder:
107
150
 
108
151
  self.total_points += max_question_points
109
152
 
110
- def construct_first_cell_question_header(self, cell_dict):
153
+ def construct_first_cell_question_header(self, cell_dict: dict) -> list[str]:
111
154
  max_question_points = sum(
112
155
  cell["points"]
113
156
  for cell in self.assertion_tests_dict.values()
@@ -136,7 +179,7 @@ class FastAPINotebookBuilder:
136
179
  return first_cell_header
137
180
 
138
181
  @staticmethod
139
- def construct_update_responses(cell_dict):
182
+ def construct_update_responses(cell_dict: dict) -> list[str]:
140
183
  update_responses = []
141
184
 
142
185
  logging_variables = cell_dict["logging_variables"]
@@ -149,7 +192,9 @@ class FastAPINotebookBuilder:
149
192
  return update_responses
150
193
 
151
194
  @staticmethod
152
- def split_list_at_marker(input_list, marker="""# END TEST CONFIG"""):
195
+ def split_list_at_marker(
196
+ input_list: list[str], marker: str = """# END TEST CONFIG"""
197
+ ) -> tuple[list[str], list[str]]:
153
198
  """
154
199
  Splits a list into two parts at the specified marker string.
155
200
 
@@ -172,7 +217,7 @@ class FastAPINotebookBuilder:
172
217
  ) # If the marker is not in the list, return the original list and an empty list
173
218
 
174
219
  @staticmethod
175
- def construct_graders(cell_dict):
220
+ def construct_graders(cell_dict: dict) -> list[str]:
176
221
  # Generate Python code
177
222
  added_code = [
178
223
  "if "
@@ -184,7 +229,7 @@ class FastAPINotebookBuilder:
184
229
  return added_code
185
230
 
186
231
  @staticmethod
187
- def construct_question_info(cell_dict):
232
+ def construct_question_info(cell_dict: dict) -> list[str]:
188
233
  question_info = []
189
234
 
190
235
  question_id = cell_dict["question"] + "-" + str(cell_dict["test_number"])
@@ -197,8 +242,12 @@ class FastAPINotebookBuilder:
197
242
 
198
243
  @staticmethod
199
244
  def insert_list_at_index(
200
- original_list, insert_list, index, line_break=True, inplace_line_break=True
201
- ):
245
+ original_list: list[str],
246
+ insert_list: list[str],
247
+ index: int,
248
+ line_break: bool = True,
249
+ inplace_line_break: bool = True,
250
+ ) -> list[str]:
202
251
  """
203
252
  Inserts a list into another list at a specific index.
204
253
 
@@ -223,7 +272,7 @@ class FastAPINotebookBuilder:
223
272
  return original_list[:index] + insert_list + original_list[index:]
224
273
 
225
274
  @staticmethod
226
- def add_import_statements_to_tests(cell_source):
275
+ def add_import_statements_to_tests(cell_source: list[str]) -> list[str]:
227
276
  """
228
277
  Adds the necessary import statements to the first cell of the notebook.
229
278
  """
@@ -241,6 +290,7 @@ class FastAPINotebookBuilder:
241
290
  " update_responses,\n",
242
291
  ")\n",
243
292
  "import os\n",
293
+ "import base64\n",
244
294
  ]
245
295
 
246
296
  for i, line in enumerate(cell_source):
@@ -251,7 +301,12 @@ class FastAPINotebookBuilder:
251
301
  ] + imports # Add a blank line for readability
252
302
  return cell_source # Exit the loop once the imports are inserted
253
303
 
254
- def extract_first_cell(self):
304
+ raise ValueError("End of test configuration not found")
305
+
306
+ # TODO: `Any` return not good; would be better to specify return type(s)
307
+ def extract_first_cell(self) -> Any:
308
+ if not self.temp_notebook:
309
+ raise ValueError("No temporary notebook file path provided")
255
310
  with open(self.temp_notebook, "r", encoding="utf-8") as f:
256
311
  notebook = json.load(f)
257
312
  if "cells" in notebook and len(notebook["cells"]) > 0:
@@ -260,13 +315,16 @@ class FastAPINotebookBuilder:
260
315
  return None
261
316
 
262
317
  @staticmethod
263
- def get_filename_and_root(path):
318
+ def get_filename_and_root(path: str) -> tuple[Path, str]:
264
319
  path_obj = Path(path).resolve() # Resolve the path to get an absolute path
265
320
  root_path = path_obj.parent # Get the parent directory
266
321
  filename = path_obj.name # Get the filename
267
322
  return root_path, filename
268
323
 
269
- def get_cell(self, cell_index):
324
+ # TODO: `Any` return not good; would be better to specify return type(s)
325
+ def get_cell(self, cell_index: int) -> Any:
326
+ if not self.temp_notebook:
327
+ raise ValueError("No temporary notebook file path provided")
270
328
  with open(self.temp_notebook, "r", encoding="utf-8") as f:
271
329
  notebook = json.load(f)
272
330
  if "cells" in notebook and len(notebook["cells"]) > cell_index:
@@ -274,7 +332,7 @@ class FastAPINotebookBuilder:
274
332
  else:
275
333
  return None
276
334
 
277
- def replace_cell_source(self, cell_index, new_source):
335
+ def replace_cell_source(self, cell_index: int, new_source: str | list[str]) -> None:
278
336
  """
279
337
  Replace the source code of a specific Jupyter notebook cell.
280
338
 
@@ -283,6 +341,8 @@ class FastAPINotebookBuilder:
283
341
  new_source (str): New source code to replace the cell's content.
284
342
  """
285
343
  # Load the notebook
344
+ if not self.temp_notebook:
345
+ raise ValueError("No temporary notebook file path provided")
286
346
  with open(self.temp_notebook, "r", encoding="utf-8") as f:
287
347
  notebook = nbformat.read(f, as_version=4)
288
348
 
@@ -301,7 +361,7 @@ class FastAPINotebookBuilder:
301
361
  print(f"Updated notebook saved to {self.temp_notebook}")
302
362
 
303
363
  @staticmethod
304
- def find_last_import_line(cell_source):
364
+ def find_last_import_line(cell_source: list[str]) -> int:
305
365
  """
306
366
  Finds the index of the last line with an import statement in a list of code lines,
307
367
  including multiline import statements.
@@ -339,7 +399,7 @@ class FastAPINotebookBuilder:
339
399
  return last_import_index
340
400
 
341
401
  @staticmethod
342
- def extract_log_variables(cell):
402
+ def extract_log_variables(cell: dict) -> list[str]:
343
403
  """Extracts log variables from the first cell."""
344
404
  if "source" in cell:
345
405
  for line in cell["source"]:
@@ -355,7 +415,8 @@ class FastAPINotebookBuilder:
355
415
  pass
356
416
  return []
357
417
 
358
- def tag_questions(cells_dict):
418
+ @staticmethod
419
+ def tag_questions(cells_dict: dict) -> dict:
359
420
  """
360
421
  Adds 'is_first' and 'is_last' boolean flags to the cells based on their position
361
422
  within the group of the same question. All cells will have both flags.
@@ -377,7 +438,7 @@ class FastAPINotebookBuilder:
377
438
  raise KeyError(f"Cell {key} is missing the 'question' key.")
378
439
 
379
440
  # Group the keys by question name
380
- question_groups = {}
441
+ question_groups: dict = {}
381
442
  for key, cell in cells_dict.items():
382
443
  question = cell.get(
383
444
  "question"
@@ -397,7 +458,9 @@ class FastAPINotebookBuilder:
397
458
 
398
459
  return cells_dict
399
460
 
400
- def question_dict(self):
461
+ def question_dict(self) -> dict:
462
+ if not self.temp_notebook:
463
+ raise ValueError("No temporary notebook file path provided")
401
464
  notebook_path = Path(self.temp_notebook)
402
465
  if not notebook_path.exists():
403
466
  raise FileNotFoundError(f"The file {notebook_path} does not exist.")
@@ -406,15 +469,14 @@ class FastAPINotebookBuilder:
406
469
  notebook = json.load(f)
407
470
 
408
471
  results_dict = {}
472
+ question_name = None # At least define the variable up front
409
473
 
410
474
  for cell_index, cell in enumerate(notebook.get("cells", [])):
411
475
  if cell.get("cell_type") == "raw":
412
476
  source = "".join(cell.get("source", ""))
413
477
  if source.strip().startswith("# BEGIN QUESTION"):
414
- question_name = re.search(r"name:\s*(.*)", source)
415
- question_name = (
416
- question_name.group(1).strip() if question_name else None
417
- )
478
+ name_match = re.search(r"name:\s*(.*)", source)
479
+ question_name = name_match.group(1).strip() if name_match else None
418
480
 
419
481
  elif cell.get("cell_type") == "code":
420
482
  source = "".join(cell.get("source", ""))
@@ -0,0 +1,190 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+
5
+ from nbformat.v4 import new_markdown_cell, new_notebook
6
+
7
+
8
+ class QuestionCollator:
9
+ def __init__(self, root_folder: str, output_path: str):
10
+ """
11
+ Initializes the QuestionCollator with the root folder and output path.
12
+
13
+ Args:
14
+ root_folder (str): Path to the root folder containing the solution files.
15
+ output_path (str): Path to save the collated notebook.
16
+ """
17
+ self.root_folder = root_folder
18
+ self.output_path = output_path
19
+
20
+ def find_solution_folders(self):
21
+ """
22
+ Finds all immediate subdirectories inside '_solution*' folders that contain notebooks.
23
+
24
+ Returns:
25
+ list: List of folder paths containing notebooks.
26
+ """
27
+ solution_folders = []
28
+
29
+ # Look for _solution* folders inside the root_folder
30
+ for dir_name in os.listdir(self.root_folder):
31
+ solution_folder_path = os.path.join(self.root_folder, dir_name)
32
+
33
+ if os.path.isdir(solution_folder_path) and dir_name.startswith("_solution"):
34
+ print(f"Found solution folder: {solution_folder_path}") # Debug output
35
+
36
+ # Now, look for immediate subdirectories inside this _solution* folder
37
+ for sub_dir in os.listdir(solution_folder_path):
38
+ sub_dir_path = os.path.join(solution_folder_path, sub_dir)
39
+
40
+ if os.path.isdir(sub_dir_path):
41
+ # Check if this subdirectory contains at least one .ipynb file
42
+ if any(f.endswith(".ipynb") for f in os.listdir(sub_dir_path)):
43
+ solution_folders.append(sub_dir_path)
44
+
45
+ print(f"Final list of solution subfolders: {solution_folders}") # Debug output
46
+ return solution_folders
47
+
48
+ def extract_questions(self, folder_path):
49
+ """
50
+ Extracts questions from all notebooks in the solution folder.
51
+
52
+ Args:
53
+ folder_path (str): Path to the solution folder.
54
+
55
+ Returns:
56
+ dict: Dictionary of categorized questions.
57
+ """
58
+ questions = {
59
+ "multiple_choice": [],
60
+ "select_many": [],
61
+ "true_false": [],
62
+ "other": [],
63
+ }
64
+
65
+ for file in os.listdir(folder_path):
66
+ if file.endswith(".ipynb"):
67
+ file_path = os.path.join(folder_path, file)
68
+ print(
69
+ f"Processing notebook: {file_path}"
70
+ ) # Print the full path of the notebook
71
+ with open(file_path, "r") as f:
72
+ content = json.load(f)
73
+
74
+ # Track whether we are inside a question block
75
+ in_question_block = False
76
+ current_question_content = []
77
+
78
+ for cell in content["cells"]:
79
+ if "# BEGIN MULTIPLE CHOICE" in cell["source"]:
80
+ # Start of a question block
81
+ in_question_block = True
82
+ current_question_content = []
83
+ elif "# END MULTIPLE CHOICE" in cell["source"]:
84
+ # End of a question block
85
+ in_question_block = False
86
+ if current_question_content:
87
+ questions["multiple_choice"].append(
88
+ {"source": "\n".join(current_question_content)}
89
+ )
90
+ current_question_content = []
91
+ elif in_question_block and cell["cell_type"] == "markdown":
92
+ # Capture markdown cells within the question block
93
+ current_question_content.append(cell["source"])
94
+
95
+ return questions
96
+
97
+ def create_collated_notebook(self, questions):
98
+ """
99
+ Creates a new notebook with questions organized by type.
100
+
101
+ Args:
102
+ questions (dict): Dictionary of categorized questions.
103
+
104
+ Returns:
105
+ Notebook: The collated notebook.
106
+ """
107
+ nb = new_notebook()
108
+
109
+ # Add Multiple Choice Questions
110
+ nb.cells.append(new_markdown_cell("# Multiple Choice Questions"))
111
+ for q in questions["multiple_choice"]:
112
+ nb.cells.append(new_markdown_cell(q["source"]))
113
+
114
+ # Add Select Many Questions
115
+ nb.cells.append(new_markdown_cell("# Select Many Questions"))
116
+ for q in questions["select_many"]:
117
+ nb.cells.append(new_markdown_cell(q["source"]))
118
+
119
+ # Add True/False Questions
120
+ nb.cells.append(new_markdown_cell("# True/False Questions"))
121
+ for q in questions["true_false"]:
122
+ nb.cells.append(new_markdown_cell(q["source"]))
123
+
124
+ # Add Other Questions
125
+ nb.cells.append(new_markdown_cell("# Other Questions"))
126
+ for q in questions["other"]:
127
+ nb.cells.append(new_markdown_cell(q["source"]))
128
+
129
+ return nb
130
+
131
+ def save_notebook(self, nb):
132
+ """
133
+ Saves the collated notebook to the specified output path.
134
+
135
+ Args:
136
+ nb (Notebook): The notebook to save.
137
+ """
138
+ import nbformat
139
+
140
+ with open(self.output_path, "w") as f:
141
+ nbformat.write(nb, f)
142
+
143
+ def collate_questions(self):
144
+ """
145
+ Collates questions from all solution folders and saves them to a new notebook.
146
+ """
147
+ solution_folders = self.find_solution_folders()
148
+ all_questions = {
149
+ "multiple_choice": [],
150
+ "select_many": [],
151
+ "true_false": [],
152
+ "other": [],
153
+ }
154
+
155
+ for folder in solution_folders:
156
+ questions = self.extract_questions(folder)
157
+ all_questions["multiple_choice"].extend(questions["multiple_choice"])
158
+ all_questions["select_many"].extend(questions["select_many"])
159
+ all_questions["true_false"].extend(questions["true_false"])
160
+ all_questions["other"].extend(questions["other"])
161
+
162
+ collated_nb = self.create_collated_notebook(all_questions)
163
+ self.save_notebook(collated_nb)
164
+ print(f"Collated notebook saved to {self.output_path}")
165
+
166
+
167
+ def main():
168
+ parser = argparse.ArgumentParser(
169
+ description="Collate questions from solution folders into a single notebook."
170
+ )
171
+ parser.add_argument(
172
+ "root_folder",
173
+ type=str,
174
+ help="Path to the root folder containing solution folders",
175
+ )
176
+ parser.add_argument(
177
+ "output_path", type=str, help="Path to save the collated notebook"
178
+ )
179
+
180
+ args = parser.parse_args()
181
+ collator = QuestionCollator(
182
+ root_folder=args.root_folder, output_path=args.output_path
183
+ )
184
+ collator.collate_questions()
185
+
186
+
187
+ if __name__ == "__main__":
188
+ import sys
189
+
190
+ sys.exit(main())
@@ -0,0 +1,76 @@
1
+ import os
2
+
3
+ import pandas as pd
4
+ import requests
5
+ from requests.auth import HTTPBasicAuth
6
+
7
+
8
+ def format_assignment_table(assignments):
9
+ # Create DataFrame
10
+ df = pd.DataFrame(assignments)
11
+
12
+ # Replacements for normalization
13
+ replacements = {
14
+ "practicequiz": "practice quiz",
15
+ "practice-quiz": "practice quiz",
16
+ "attend": "attendance",
17
+ "attendance": "attendance",
18
+ }
19
+
20
+ # Remove assignments of type 'test'
21
+ remove_assignments = ["test"]
22
+
23
+ # Apply replacements
24
+ df["assignment_name"] = df["assignment_type"].replace(replacements)
25
+
26
+ # Filter out specific assignment types
27
+ df = df[~df["assignment_type"].isin(remove_assignments)]
28
+
29
+ # Sort by week number and assignment name
30
+ df = df.sort_values(by=["assignment_name", "week_number"]).reset_index(drop=True)
31
+
32
+ return df
33
+
34
+
35
+ def get_student_grades(student_username):
36
+ # Get env variables here, in the function, rather than globally
37
+ api_base_url = os.getenv("DB_URL")
38
+ student_user = os.getenv("user_name_student")
39
+ student_pw = os.getenv("keys_student")
40
+
41
+ if not api_base_url or not student_user or not student_pw:
42
+ raise ValueError("Environment variables not set")
43
+
44
+ params = {"username": student_username}
45
+ res = requests.get(
46
+ url=api_base_url.rstrip("/") + "/student-grades-testing",
47
+ params=params,
48
+ auth=HTTPBasicAuth(student_user, student_pw),
49
+ )
50
+ [assignments, sub] = res.json()
51
+
52
+ assignments_df = format_assignment_table(assignments)
53
+
54
+ return assignments_df, pd.DataFrame(sub)
55
+
56
+
57
+ def filter_assignments(df, max_week=None, exclude_types=None):
58
+ """
59
+ Remove assignments with week_number greater than max_week
60
+ or with specific assignment types.
61
+
62
+ :param df: DataFrame containing assignments.
63
+ :param max_week: Maximum allowed week_number (int).
64
+ :param exclude_types: A single assignment type or a list of assignment types to exclude.
65
+ :return: Filtered DataFrame.
66
+ """
67
+ if max_week is not None:
68
+ df = df[df["week_number"] <= max_week]
69
+
70
+ if exclude_types is not None:
71
+ # Ensure exclude_types is a list
72
+ if not isinstance(exclude_types, (list, tuple, set)):
73
+ exclude_types = [exclude_types]
74
+ df = df[~df["assignment_type"].isin(exclude_types)]
75
+
76
+ return df