PyKubeGrader 0.1.22__py3-none-any.whl → 0.2.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- {PyKubeGrader-0.1.22.dist-info → PyKubeGrader-0.2.0.dist-info}/METADATA +3 -1
- PyKubeGrader-0.2.0.dist-info/RECORD +32 -0
- {PyKubeGrader-0.1.22.dist-info → PyKubeGrader-0.2.0.dist-info}/entry_points.txt +1 -0
- pykubegrader/build/__init__.py +1 -0
- pykubegrader/build/api_notebook_builder.py +2 -2
- pykubegrader/build/build_folder.py +146 -26
- pykubegrader/build/clean_folder.py +45 -0
- pykubegrader/graders/__init__.py +1 -0
- pykubegrader/graders/late_assignments.py +45 -0
- pykubegrader/log_parser/__init__.py +1 -0
- pykubegrader/log_parser/parse.ipynb +1 -1
- pykubegrader/log_parser/parse.py +15 -15
- pykubegrader/telemetry.py +28 -65
- pykubegrader/utils.py +2 -2
- pykubegrader/validate.py +11 -9
- pykubegrader/widgets/__init__.py +0 -18
- pykubegrader/widgets/true_false.py +2 -2
- pykubegrader/widgets_base/multi_select.py +3 -1
- pykubegrader/widgets_base/reading.py +3 -1
- pykubegrader/widgets_base/select.py +3 -1
- PyKubeGrader-0.1.22.dist-info/RECORD +0 -27
- {PyKubeGrader-0.1.22.dist-info → PyKubeGrader-0.2.0.dist-info}/LICENSE.txt +0 -0
- {PyKubeGrader-0.1.22.dist-info → PyKubeGrader-0.2.0.dist-info}/WHEEL +0 -0
- {PyKubeGrader-0.1.22.dist-info → PyKubeGrader-0.2.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: PyKubeGrader
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
4
4
|
Summary: Add a short description here!
|
5
5
|
Home-page: https://github.com/pyscaffold/pyscaffold/
|
6
6
|
Author: jagar2
|
@@ -24,6 +24,8 @@ Requires-Dist: requests
|
|
24
24
|
Requires-Dist: ruff
|
25
25
|
Requires-Dist: setuptools
|
26
26
|
Requires-Dist: sphinx
|
27
|
+
Requires-Dist: types-python-dateutil
|
28
|
+
Requires-Dist: types-pyyaml
|
27
29
|
Requires-Dist: types-requests
|
28
30
|
Requires-Dist: types-setuptools
|
29
31
|
Provides-Extra: testing
|
@@ -0,0 +1,32 @@
|
|
1
|
+
pykubegrader/__init__.py,sha256=AoAkdfIjDDZGWLlsIRENNq06L9h46kDGBIE8vRmsCfg,311
|
2
|
+
pykubegrader/initialize.py,sha256=t3iSdeIcndfY8LoHBVUEZfTW6sUWHyeFLirKo4GwSQE,3328
|
3
|
+
pykubegrader/telemetry.py,sha256=jRInaDqIpdeT7F0rxLJgO38lA-SMtmLcYE8nEAGah1Q,4922
|
4
|
+
pykubegrader/utils.py,sha256=T3GYnLnTL9VXjTZNPr00sUgMgobQYsNTGwynMyXdvHk,696
|
5
|
+
pykubegrader/validate.py,sha256=2KLSB3wfFZbBh1NGgmrOV073paKAgrQz4AgA6LmCIj4,11076
|
6
|
+
pykubegrader/build/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
7
|
+
pykubegrader/build/api_notebook_builder.py,sha256=FEaTj1fEsPAes7KNrPuhClEvKzLuOL3wG7Hgr7FQc-o,20083
|
8
|
+
pykubegrader/build/build_folder.py,sha256=nR06JHvgMio2p-95GsdqCXlfzZ6xJgAJNFPSjmg7QhI,70722
|
9
|
+
pykubegrader/build/clean_folder.py,sha256=8N0KyL4eXRs0DCw-V_2jR9igtFs_mOFMQufdL6tD-38,1323
|
10
|
+
pykubegrader/graders/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
11
|
+
pykubegrader/graders/late_assignments.py,sha256=_2-rA5RqO0BWY9WAQA_mbCxxPKTOiJOl-byD2CYWaE0,1393
|
12
|
+
pykubegrader/log_parser/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
13
|
+
pykubegrader/log_parser/parse.ipynb,sha256=kPTo9gqhDineT7yT56P9xrQP1OKTf6Ro-iR4xlJ-or8,10610
|
14
|
+
pykubegrader/log_parser/parse.py,sha256=uw8lxWVh0FTaWi-bVmpMBbqwTXIHlJtB9gPc1qKvm4I,7040
|
15
|
+
pykubegrader/widgets/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
16
|
+
pykubegrader/widgets/multiple_choice.py,sha256=NjD3-uXSnibpUQ0mO3hRp_O-rynFyl0Dz6IXE4tnCRI,2078
|
17
|
+
pykubegrader/widgets/reading_question.py,sha256=y30_swHwzH8LrT8deWTnxctAAmR8BSxTlXAqMgUrAT4,3031
|
18
|
+
pykubegrader/widgets/select_many.py,sha256=l7YQ8QT5k71j36KC1f5LmKIAX2bXpvMDGc6nqIJ1PeQ,4116
|
19
|
+
pykubegrader/widgets/student_info.py,sha256=xhQgKehk1r5e6N_hnjAIovLdPvQju6ZqQTOiPG0aevg,3568
|
20
|
+
pykubegrader/widgets/style.py,sha256=fVBMYy_a6Yoz21avNpiORWC3f5FD-OrVpaZ3npmunvs,1656
|
21
|
+
pykubegrader/widgets/true_false.py,sha256=D45bjRLaAcNzsSlWPgxwTXGVZPE7PER34S30V6PjEXU,2807
|
22
|
+
pykubegrader/widgets/types_question.py,sha256=kZdRRXyFzOtYTmGdC7XWb_2oaxqg1WSuLcQn_sTj6Qc,2300
|
23
|
+
pykubegrader/widgets_base/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
24
|
+
pykubegrader/widgets_base/multi_select.py,sha256=Cl0IN21wXLZuFu-zC65aS9tD4jMfzCRJ2DPjHao5_Ak,4044
|
25
|
+
pykubegrader/widgets_base/reading.py,sha256=_vjUPynqmJe_R4vf-7hVhGnQR726S9GL6qT8bflBXBM,5383
|
26
|
+
pykubegrader/widgets_base/select.py,sha256=Fw3uFNOIWo1a3CvlzSx23bvi6bSmA3TqutuRbhD4Dp8,2525
|
27
|
+
PyKubeGrader-0.2.0.dist-info/LICENSE.txt,sha256=YTp-Ewc8Kems8PJEE27KnBPFnZSxoWvSg7nnknzPyYw,1546
|
28
|
+
PyKubeGrader-0.2.0.dist-info/METADATA,sha256=XEGRhBLAhcEcQVOhEkNcQi9orqofhyuNCQBwkALUJZ0,2729
|
29
|
+
PyKubeGrader-0.2.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
30
|
+
PyKubeGrader-0.2.0.dist-info/entry_points.txt,sha256=UPMdTT46fQwTYJWtrUwIWIbXbwyOPfNQgBFRa0frWzw,138
|
31
|
+
PyKubeGrader-0.2.0.dist-info/top_level.txt,sha256=e550Klfze6higFxER1V62fnGOcIgiKRbsrl9CC4UdtQ,13
|
32
|
+
PyKubeGrader-0.2.0.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -93,7 +93,7 @@ class FastAPINotebookBuilder:
|
|
93
93
|
self.replace_cell_source(cell_index, updated_cell_source)
|
94
94
|
|
95
95
|
def compute_max_points_free_response(self):
|
96
|
-
for
|
96
|
+
for cell_dict in self.assertion_tests_dict.values():
|
97
97
|
# gets the question name from the first cell to not double count
|
98
98
|
if cell_dict["is_first"]:
|
99
99
|
# get the max points for the question
|
@@ -380,7 +380,7 @@ class FastAPINotebookBuilder:
|
|
380
380
|
question_groups[question].append(key)
|
381
381
|
|
382
382
|
# Add 'is_first' and 'is_last' flags to all cells
|
383
|
-
for
|
383
|
+
for keys in question_groups.values():
|
384
384
|
test_number = 1
|
385
385
|
for i, key in enumerate(keys):
|
386
386
|
cells_dict[key]["is_first"] = i == 0
|
@@ -8,6 +8,16 @@ import shutil
|
|
8
8
|
import subprocess
|
9
9
|
import sys
|
10
10
|
from dataclasses import dataclass, field
|
11
|
+
from datetime import datetime
|
12
|
+
|
13
|
+
import requests
|
14
|
+
import yaml
|
15
|
+
from dateutil import parser # For robust datetime parsing
|
16
|
+
|
17
|
+
try:
|
18
|
+
from pykubegrader.build.passwords import password, user
|
19
|
+
except: # noqa: E722
|
20
|
+
print("Passwords not found, cannot access database")
|
11
21
|
|
12
22
|
import nbformat
|
13
23
|
|
@@ -44,8 +54,24 @@ class NotebookProcessor:
|
|
44
54
|
Raises:
|
45
55
|
OSError: If the solutions folder cannot be created due to permissions or other filesystem issues.
|
46
56
|
"""
|
57
|
+
if self.check_if_file_in_folder("assignment_config.yaml"):
|
58
|
+
# Parse the YAML content
|
59
|
+
with open(f"{self.root_folder}/assignment_config.yaml", "r") as file:
|
60
|
+
data = yaml.safe_load(file)
|
61
|
+
# Extract assignment details
|
62
|
+
assignment = data.get("assignment", {})
|
63
|
+
week_num = assignment.get("week")
|
64
|
+
self.assignment_type = assignment.get("assignment_type")
|
65
|
+
else:
|
66
|
+
self.assignment_type = self.assignment_tag.split("-")[0].lower()
|
67
|
+
week_num = self.assignment_tag.split("-")[-1]
|
68
|
+
|
69
|
+
self.week = f"week_{week_num}"
|
70
|
+
|
47
71
|
# Define the folder to store solutions and ensure it exists
|
48
72
|
self.solutions_folder = os.path.join(self.root_folder, "_solutions")
|
73
|
+
self.assignment_total_points = 0
|
74
|
+
|
49
75
|
os.makedirs(
|
50
76
|
self.solutions_folder, exist_ok=True
|
51
77
|
) # Create the folder if it doesn't exist
|
@@ -127,6 +153,84 @@ class NotebookProcessor:
|
|
127
153
|
self.total_point_log, json_file, indent=4
|
128
154
|
) # `indent=4` for pretty formatting
|
129
155
|
|
156
|
+
if self.check_if_file_in_folder("assignment_config.yaml"):
|
157
|
+
self.add_assignment()
|
158
|
+
|
159
|
+
def build_payload(self, yaml_content):
|
160
|
+
"""
|
161
|
+
Reads YAML content for an assignment and returns Python variables.
|
162
|
+
|
163
|
+
Args:
|
164
|
+
yaml_content (str): The YAML file path to parse.
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
dict: A dictionary containing the parsed assignment data.
|
168
|
+
"""
|
169
|
+
# Parse the YAML content
|
170
|
+
with open(yaml_content, "r") as file:
|
171
|
+
data = yaml.safe_load(file)
|
172
|
+
|
173
|
+
# Extract assignment details
|
174
|
+
assignment = data.get("assignment", {})
|
175
|
+
week = assignment.get("week")
|
176
|
+
assignment_type = assignment.get("assignment_type")
|
177
|
+
due_date_str = assignment.get("due_date")
|
178
|
+
|
179
|
+
# Convert due_date to a datetime object if available
|
180
|
+
due_date = None
|
181
|
+
if due_date_str:
|
182
|
+
try:
|
183
|
+
due_date = parser.parse(due_date_str) # Automatically handles timezones
|
184
|
+
except ValueError as e:
|
185
|
+
print(f"Error parsing due_date: {e}")
|
186
|
+
|
187
|
+
title = f"Week {week} - {assignment_type}"
|
188
|
+
|
189
|
+
# Return the extracted details as a dictionary
|
190
|
+
return {
|
191
|
+
"title": title,
|
192
|
+
"description": str(week),
|
193
|
+
"due_date": due_date,
|
194
|
+
"max_score": int(self.assignment_total_points),
|
195
|
+
}
|
196
|
+
|
197
|
+
def add_assignment(self):
|
198
|
+
"""
|
199
|
+
Sends a POST request to add an assignment.
|
200
|
+
"""
|
201
|
+
# Define the URL
|
202
|
+
url = "https://engr-131-api.eastus.cloudapp.azure.com/assignments"
|
203
|
+
|
204
|
+
# Build the payload
|
205
|
+
payload = self.build_payload(f"{self.root_folder}/assignment_config.yaml")
|
206
|
+
|
207
|
+
# Define HTTP Basic Authentication
|
208
|
+
auth = (user(), password())
|
209
|
+
|
210
|
+
# Define headers
|
211
|
+
headers = {"Content-Type": "application/json"}
|
212
|
+
|
213
|
+
# Serialize the payload with the custom JSON encoder
|
214
|
+
serialized_payload = json.dumps(payload, default=self.json_serial)
|
215
|
+
|
216
|
+
# Send the POST request
|
217
|
+
response = requests.post(
|
218
|
+
url, data=serialized_payload, headers=headers, auth=auth
|
219
|
+
)
|
220
|
+
|
221
|
+
# Print the response
|
222
|
+
print(f"Status Code: {response.status_code}")
|
223
|
+
try:
|
224
|
+
print(f"Response: {response.json()}")
|
225
|
+
except ValueError:
|
226
|
+
print(f"Response: {response.text}")
|
227
|
+
|
228
|
+
def check_if_file_in_folder(self, file):
|
229
|
+
for root, _, files in os.walk(self.root_folder):
|
230
|
+
if file in files:
|
231
|
+
return True
|
232
|
+
return False
|
233
|
+
|
130
234
|
def _print_and_log(self, message):
|
131
235
|
"""
|
132
236
|
Logs a message and optionally prints it to the console.
|
@@ -215,29 +319,42 @@ class NotebookProcessor:
|
|
215
319
|
else:
|
216
320
|
self._print_and_log(f"Notebook already in destination: {new_notebook_path}")
|
217
321
|
|
218
|
-
solution_path_1,
|
322
|
+
solution_path_1, question_path_1 = self.multiple_choice_parser(
|
219
323
|
temp_notebook_path, new_notebook_path
|
220
324
|
)
|
221
|
-
solution_path_2,
|
325
|
+
solution_path_2, question_path_2 = self.true_false_parser(
|
222
326
|
temp_notebook_path, new_notebook_path
|
223
327
|
)
|
224
|
-
solution_path_3,
|
328
|
+
solution_path_3, question_path_3 = self.select_many_parser(
|
225
329
|
temp_notebook_path, new_notebook_path
|
226
330
|
)
|
227
331
|
|
228
332
|
if any([solution_path_1, solution_path_2, solution_path_3]) is not None:
|
229
333
|
solution_path = solution_path_1 or solution_path_2 or solution_path_3
|
230
334
|
|
335
|
+
if any([question_path_1, question_path_2, question_path_3]) is not None:
|
336
|
+
question_path = question_path_1 or question_path_2 or question_path_3
|
337
|
+
|
231
338
|
student_notebook, self.otter_total_points = self.free_response_parser(
|
232
339
|
temp_notebook_path, notebook_subfolder, notebook_name
|
233
340
|
)
|
234
341
|
|
235
342
|
# If Otter does not run, move the student file to the main directory
|
236
343
|
if student_notebook is None:
|
344
|
+
clean_notebook(temp_notebook_path)
|
237
345
|
path_ = shutil.copy(temp_notebook_path, self.root_folder)
|
346
|
+
path_2 = shutil.move(
|
347
|
+
question_path,
|
348
|
+
os.path.join(
|
349
|
+
os.path.dirname(temp_notebook_path), os.path.basename(question_path)
|
350
|
+
),
|
351
|
+
)
|
238
352
|
self._print_and_log(
|
239
353
|
f"Copied and cleaned student notebook: {path_} -> {self.root_folder}"
|
240
354
|
)
|
355
|
+
self._print_and_log(
|
356
|
+
f"Copied Questions to: {path_2} -> {os.path.join(os.path.dirname(temp_notebook_path), os.path.basename(question_path))}"
|
357
|
+
)
|
241
358
|
|
242
359
|
# Move the solution file to the autograder folder
|
243
360
|
if solution_path is not None:
|
@@ -298,6 +415,8 @@ class NotebookProcessor:
|
|
298
415
|
+ self.otter_total_points
|
299
416
|
)
|
300
417
|
|
418
|
+
self.assignment_total_points += total_points
|
419
|
+
|
301
420
|
self.total_point_log.update({notebook_name: total_points})
|
302
421
|
|
303
422
|
def free_response_parser(
|
@@ -354,9 +473,9 @@ class NotebookProcessor:
|
|
354
473
|
notebook_subfolder, "dist", "student", f"{notebook_name}.ipynb"
|
355
474
|
)
|
356
475
|
|
357
|
-
NotebookProcessor.add_initialization_code(
|
358
|
-
|
359
|
-
|
476
|
+
NotebookProcessor.add_initialization_code(
|
477
|
+
student_notebook, self.week, self.assignment_type
|
478
|
+
)
|
360
479
|
|
361
480
|
NotebookProcessor.replace_temp_in_notebook(
|
362
481
|
student_notebook, student_notebook
|
@@ -367,6 +486,9 @@ class NotebookProcessor:
|
|
367
486
|
NotebookProcessor.replace_temp_in_notebook(
|
368
487
|
autograder_notebook, autograder_notebook
|
369
488
|
)
|
489
|
+
|
490
|
+
clean_notebook(student_notebook)
|
491
|
+
|
370
492
|
shutil.copy(student_notebook, self.root_folder)
|
371
493
|
self._print_and_log(
|
372
494
|
f"Copied and cleaned student notebook: {student_notebook} -> {self.root_folder}"
|
@@ -378,9 +500,18 @@ class NotebookProcessor:
|
|
378
500
|
|
379
501
|
return student_notebook, out.total_points
|
380
502
|
else:
|
381
|
-
NotebookProcessor.add_initialization_code(
|
503
|
+
NotebookProcessor.add_initialization_code(
|
504
|
+
temp_notebook_path, self.week, self.assignment_type
|
505
|
+
)
|
382
506
|
return None, 0
|
383
507
|
|
508
|
+
@staticmethod
|
509
|
+
def json_serial(obj):
|
510
|
+
"""JSON serializer for objects not serializable by default."""
|
511
|
+
if isinstance(obj, datetime):
|
512
|
+
return obj.isoformat()
|
513
|
+
raise TypeError(f"Type {type(obj)} not serializable")
|
514
|
+
|
384
515
|
@staticmethod
|
385
516
|
def remove_assignment_config_cells(notebook_path):
|
386
517
|
# Read the notebook
|
@@ -399,13 +530,13 @@ class NotebookProcessor:
|
|
399
530
|
nbformat.write(notebook, f)
|
400
531
|
|
401
532
|
@staticmethod
|
402
|
-
def add_initialization_code(notebook_path):
|
533
|
+
def add_initialization_code(notebook_path, week, assignment_type):
|
403
534
|
# finds the first code cell
|
404
535
|
index, cell = find_first_code_cell(notebook_path)
|
405
536
|
cell = cell["source"]
|
406
537
|
import_text = "from pykubegrader.initialize import initialize_assignment\n"
|
538
|
+
cell += f'\nresponses = initialize_assignment("{os.path.splitext(os.path.basename(notebook_path))[0]}", "{week}", "{assignment_type}" )\n'
|
407
539
|
cell = f"{import_text}\n" + cell
|
408
|
-
cell += f'\nresponses = initialize_assignment("{os.path.splitext(os.path.basename(notebook_path))[0]}")\n'
|
409
540
|
replace_cell_source(notebook_path, index, cell)
|
410
541
|
|
411
542
|
def multiple_choice_parser(self, temp_notebook_path, new_notebook_path):
|
@@ -426,15 +557,11 @@ class NotebookProcessor:
|
|
426
557
|
|
427
558
|
data = NotebookProcessor.merge_metadata(value, data)
|
428
559
|
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
data, output_file=solution_path
|
433
|
-
)
|
560
|
+
self.mcq_total_points = self.generate_solution_MCQ(
|
561
|
+
data, output_file=solution_path
|
562
|
+
)
|
434
563
|
|
435
|
-
|
436
|
-
f"{new_notebook_path.replace(".ipynb", "")}_questions.py"
|
437
|
-
)
|
564
|
+
question_path = f"{new_notebook_path.replace(".ipynb", "")}_questions.py"
|
438
565
|
|
439
566
|
generate_mcq_file(data, output_file=question_path)
|
440
567
|
|
@@ -854,13 +981,6 @@ class NotebookProcessor:
|
|
854
981
|
os.rename(old_file_path, new_file_path)
|
855
982
|
logging.info(f"Renamed: {old_file_path} -> {new_file_path}")
|
856
983
|
|
857
|
-
@staticmethod
|
858
|
-
def clean_notebook(notebook_path):
|
859
|
-
"""
|
860
|
-
Cleans a Jupyter notebook to remove unwanted cells and set cell metadata.
|
861
|
-
"""
|
862
|
-
clean_notebook(notebook_path)
|
863
|
-
|
864
984
|
|
865
985
|
def extract_raw_cells(ipynb_file, heading="# BEGIN MULTIPLE CHOICE"):
|
866
986
|
"""
|
@@ -1572,7 +1692,7 @@ def generate_tf_file(data_dict, output_file="tf_questions.py"):
|
|
1572
1692
|
|
1573
1693
|
# Define header lines
|
1574
1694
|
header_lines = [
|
1575
|
-
"from pykubegrader.widgets.true_false import TFQuestion,
|
1695
|
+
"from pykubegrader.widgets.true_false import TFQuestion, TFStyle\n",
|
1576
1696
|
"import pykubegrader.initialize\n",
|
1577
1697
|
"import panel as pn\n\n",
|
1578
1698
|
"pn.extension()\n\n",
|
@@ -1592,7 +1712,7 @@ def generate_tf_file(data_dict, output_file="tf_questions.py"):
|
|
1592
1712
|
f.write(" def __init__(self):\n")
|
1593
1713
|
f.write(" super().__init__(\n")
|
1594
1714
|
f.write(f" title=f'{q_value['question_text']}',\n")
|
1595
|
-
f.write(" style=
|
1715
|
+
f.write(" style=TFStyle,\n")
|
1596
1716
|
f.write(
|
1597
1717
|
f" question_number={q_value['question number']},\n"
|
1598
1718
|
)
|
@@ -0,0 +1,45 @@
|
|
1
|
+
import argparse
|
2
|
+
import os
|
3
|
+
import shutil
|
4
|
+
import sys
|
5
|
+
|
6
|
+
|
7
|
+
class FolderCleaner:
|
8
|
+
def __init__(self, root_folder: str):
|
9
|
+
"""
|
10
|
+
Initializes the FolderCleaner with the root folder to clean.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
root_folder (str): Path to the root folder to start cleaning.
|
14
|
+
"""
|
15
|
+
self.root_folder = root_folder
|
16
|
+
|
17
|
+
def delete_dist_folders(self):
|
18
|
+
"""
|
19
|
+
Recursively deletes all folders named 'dist' starting from the root folder.
|
20
|
+
"""
|
21
|
+
for dirpath, dirnames, filenames in os.walk(self.root_folder, topdown=False):
|
22
|
+
if "dist" in dirnames:
|
23
|
+
dist_path = os.path.join(dirpath, "dist")
|
24
|
+
try:
|
25
|
+
shutil.rmtree(dist_path)
|
26
|
+
print(f"Deleted: {dist_path}")
|
27
|
+
except Exception as e:
|
28
|
+
print(f"Failed to delete {dist_path}: {e}")
|
29
|
+
|
30
|
+
|
31
|
+
def main():
|
32
|
+
parser = argparse.ArgumentParser(
|
33
|
+
description="Recursively delete all folders named 'dist' starting from a specified root folder."
|
34
|
+
)
|
35
|
+
parser.add_argument(
|
36
|
+
"root_folder", type=str, help="Path to the root folder to process"
|
37
|
+
)
|
38
|
+
|
39
|
+
args = parser.parse_args()
|
40
|
+
cleaner = FolderCleaner(root_folder=args.root_folder)
|
41
|
+
cleaner.delete_dist_folders()
|
42
|
+
|
43
|
+
|
44
|
+
if __name__ == "__main__":
|
45
|
+
sys.exit(main())
|
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -0,0 +1,45 @@
|
|
1
|
+
import datetime
|
2
|
+
|
3
|
+
import numpy as np
|
4
|
+
|
5
|
+
|
6
|
+
def calculate_late_submission(
|
7
|
+
due: str,
|
8
|
+
submitted: str,
|
9
|
+
Q0: int = 100,
|
10
|
+
Q_min: int = 40,
|
11
|
+
k: float = 6.88e-5,
|
12
|
+
) -> float:
|
13
|
+
"""
|
14
|
+
Calculate the percentage value based on an exponential decay model
|
15
|
+
with respect to a due date, using datetime string inputs.
|
16
|
+
|
17
|
+
Parameters:
|
18
|
+
- due_date_str (str): The due date as a string in the format "%Y-%m-%d %H:%M:%S".
|
19
|
+
- submission_date (str): The comparison date as a string in the format "%Y-%m-%d %H:%M:%S".
|
20
|
+
- Q0 (float): Initial value (default is 100).
|
21
|
+
- Q_min (float): Minimum value (default is 40).
|
22
|
+
- k (float): Decay constant per minute (default is 6.88e-5).
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
- float: The percentage value after decay, bounded between Q_min and Q0.
|
26
|
+
"""
|
27
|
+
|
28
|
+
# Convert datetime strings to UNIX timestamps
|
29
|
+
due_date = datetime.datetime.strptime(due, "%Y-%m-%d %H:%M:%S")
|
30
|
+
submitted_date = datetime.datetime.strptime(submitted, "%Y-%m-%d %H:%M:%S")
|
31
|
+
|
32
|
+
# Calculate time difference in seconds
|
33
|
+
time_difference = (submitted_date - due_date).total_seconds()
|
34
|
+
|
35
|
+
# Convert time difference from seconds to minutes
|
36
|
+
time_in_minutes = time_difference / 60.0
|
37
|
+
|
38
|
+
# Calculate the exponential decay
|
39
|
+
Q: float = Q0 * np.exp(-k * time_in_minutes)
|
40
|
+
|
41
|
+
# Apply floor and ceiling conditions
|
42
|
+
Q = np.maximum(Q, Q_min)
|
43
|
+
Q = np.minimum(Q, Q0)
|
44
|
+
|
45
|
+
return Q
|
@@ -0,0 +1 @@
|
|
1
|
+
|
pykubegrader/log_parser/parse.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
from dataclasses import dataclass, field
|
2
|
-
from typing import
|
2
|
+
from typing import Optional
|
3
3
|
|
4
4
|
|
5
5
|
@dataclass
|
@@ -9,16 +9,16 @@ class LogParser:
|
|
9
9
|
Handles both assignment info and question-level details.
|
10
10
|
"""
|
11
11
|
|
12
|
-
log_lines:
|
12
|
+
log_lines: list[str]
|
13
13
|
week_tag: Optional[str] = None
|
14
|
-
student_info:
|
15
|
-
assignments:
|
14
|
+
student_info: dict[str, str] = field(default_factory=dict)
|
15
|
+
assignments: dict[str, dict] = field(default_factory=dict)
|
16
16
|
|
17
|
-
def parse_logs(self):
|
17
|
+
def parse_logs(self) -> None:
|
18
18
|
"""
|
19
19
|
Main method to parse logs and populate student_info and assignments.
|
20
20
|
"""
|
21
|
-
unique_students = set()
|
21
|
+
unique_students: set[str] = set()
|
22
22
|
|
23
23
|
self._find_all_questions()
|
24
24
|
|
@@ -41,13 +41,13 @@ class LogParser:
|
|
41
41
|
):
|
42
42
|
self._process_assignment_entry(line)
|
43
43
|
|
44
|
-
def _find_all_questions(self):
|
44
|
+
def _find_all_questions(self) -> None:
|
45
45
|
"""
|
46
46
|
Finds all questions in the log_lines and returns a list of them.
|
47
47
|
"""
|
48
48
|
questions = []
|
49
49
|
for line in self.log_lines:
|
50
|
-
if self.week_tag in line:
|
50
|
+
if self.week_tag and self.week_tag in line:
|
51
51
|
parts = line.split(",")
|
52
52
|
question_tag = parts[3].strip()
|
53
53
|
if question_tag not in questions:
|
@@ -60,7 +60,7 @@ class LogParser:
|
|
60
60
|
"""
|
61
61
|
return line.startswith("Student Info")
|
62
62
|
|
63
|
-
def _process_student_info(self, line: str, unique_students: set):
|
63
|
+
def _process_student_info(self, line: str, unique_students: set) -> None:
|
64
64
|
"""
|
65
65
|
Processes a line containing student information.
|
66
66
|
Raises an error if multiple unique students are found.
|
@@ -83,7 +83,7 @@ class LogParser:
|
|
83
83
|
"timestamp": parts[3].strip(),
|
84
84
|
}
|
85
85
|
|
86
|
-
def _process_assignment_header(self, line: str):
|
86
|
+
def _process_assignment_header(self, line: str) -> None:
|
87
87
|
parts = line.split(",")
|
88
88
|
assignment_tag = parts[0].strip()
|
89
89
|
if assignment_tag.startswith("total-points"):
|
@@ -105,7 +105,7 @@ class LogParser:
|
|
105
105
|
self.assignments[notebook_name]["max_points"] = total_points_value
|
106
106
|
self.assignments[notebook_name]["latest_timestamp"] = timestamp
|
107
107
|
|
108
|
-
def _process_assignment_entry(self, line: str):
|
108
|
+
def _process_assignment_entry(self, line: str) -> None:
|
109
109
|
"""
|
110
110
|
Processes a line containing an assignment entry.
|
111
111
|
Adds it to the assignments dictionary.
|
@@ -141,7 +141,7 @@ class LogParser:
|
|
141
141
|
if timestamp > self.assignments[assignment_tag]["latest_timestamp"]:
|
142
142
|
self.assignments[assignment_tag]["latest_timestamp"] = timestamp
|
143
143
|
|
144
|
-
def _extract_total_points(self, parts:
|
144
|
+
def _extract_total_points(self, parts: list[str]) -> Optional[float]:
|
145
145
|
"""
|
146
146
|
Extracts the total-points value from the parts array of a total-points line.
|
147
147
|
"""
|
@@ -150,17 +150,17 @@ class LogParser:
|
|
150
150
|
except (ValueError, IndexError):
|
151
151
|
return None
|
152
152
|
|
153
|
-
def calculate_total_scores(self):
|
153
|
+
def calculate_total_scores(self) -> None:
|
154
154
|
"""
|
155
155
|
Calculates total scores for each assignment by summing the 'score_earned'
|
156
156
|
of its questions, and sets 'total_points' if it was not specified.
|
157
157
|
"""
|
158
|
-
for
|
158
|
+
for data in self.assignments.values():
|
159
159
|
# Sum of all question score_earned
|
160
160
|
total_score = sum(q["score_earned"] for q in data["questions"].values())
|
161
161
|
data["total_score"] = total_score
|
162
162
|
|
163
|
-
def get_results(self) ->
|
163
|
+
def get_results(self) -> dict[str, dict]:
|
164
164
|
"""
|
165
165
|
Returns the parsed results as a hierarchical dictionary with three sections:
|
166
166
|
"""
|
pykubegrader/telemetry.py
CHANGED
@@ -11,27 +11,24 @@ from IPython.core.interactiveshell import ExecutionInfo
|
|
11
11
|
from requests import Response
|
12
12
|
from requests.auth import HTTPBasicAuth
|
13
13
|
|
14
|
-
#
|
14
|
+
#
|
15
|
+
# Logging setup
|
16
|
+
#
|
17
|
+
|
18
|
+
# Logger for cell execution
|
15
19
|
logger_code = logging.getLogger("code_logger")
|
16
20
|
logger_code.setLevel(logging.INFO)
|
17
21
|
|
18
22
|
file_handler_code = logging.FileHandler(".output_code.log")
|
19
23
|
file_handler_code.setLevel(logging.INFO)
|
20
|
-
|
21
|
-
# formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
22
|
-
# file_handler_code.setFormatter(formatter)
|
23
|
-
|
24
24
|
logger_code.addHandler(file_handler_code)
|
25
25
|
|
26
|
-
# Logger for .
|
26
|
+
# Logger for question scores etc.
|
27
27
|
logger_reduced = logging.getLogger("reduced_logger")
|
28
28
|
logger_reduced.setLevel(logging.INFO)
|
29
29
|
|
30
30
|
file_handler_reduced = logging.FileHandler(".output_reduced.log")
|
31
31
|
file_handler_reduced.setLevel(logging.INFO)
|
32
|
-
|
33
|
-
# file_handler_reduced.setFormatter(formatter)
|
34
|
-
|
35
32
|
logger_reduced.addHandler(file_handler_reduced)
|
36
33
|
|
37
34
|
#
|
@@ -55,7 +52,7 @@ def encrypt_to_b64(message: str) -> str:
|
|
55
52
|
return encrypted_b64
|
56
53
|
|
57
54
|
|
58
|
-
def ensure_responses() -> dict:
|
55
|
+
def ensure_responses() -> dict[str, Any]:
|
59
56
|
with open(".responses.json", "a") as _:
|
60
57
|
pass
|
61
58
|
|
@@ -125,28 +122,34 @@ def update_responses(key: str, value) -> dict:
|
|
125
122
|
#
|
126
123
|
|
127
124
|
|
128
|
-
#
|
125
|
+
# TODO: Improve error handling
|
129
126
|
def score_question(
|
130
|
-
student_email: str,
|
131
|
-
assignment: str,
|
132
|
-
question: str,
|
133
|
-
submission: str,
|
134
127
|
term: str = "winter_2025",
|
135
|
-
base_url: str = "https://engr-131-api.eastus.cloudapp.azure.com
|
136
|
-
) ->
|
128
|
+
base_url: str = "https://engr-131-api.eastus.cloudapp.azure.com",
|
129
|
+
) -> None:
|
137
130
|
url = base_url + "/live-scorer"
|
138
131
|
|
139
|
-
|
140
|
-
|
132
|
+
responses = ensure_responses()
|
133
|
+
|
134
|
+
payload: dict[str, Any] = {
|
135
|
+
"student_email": f'{responses["jhub_user"]}@drexel.edu',
|
141
136
|
"term": term,
|
142
|
-
"
|
143
|
-
"
|
144
|
-
"
|
137
|
+
"week": responses["week"],
|
138
|
+
"assignment": responses["assignment_type"],
|
139
|
+
"question": f'_{responses["assignment"]}',
|
140
|
+
"responses": responses,
|
145
141
|
}
|
146
142
|
|
147
143
|
res = requests.post(url, json=payload, auth=HTTPBasicAuth("student", "capture"))
|
148
144
|
|
149
|
-
|
145
|
+
res_data: dict[str, tuple[float, float]] = res.json()
|
146
|
+
|
147
|
+
for question, (points_earned, max_points) in res_data.items():
|
148
|
+
log_variable(
|
149
|
+
assignment_name=responses["assignment"],
|
150
|
+
value=f"{points_earned}, {max_points}",
|
151
|
+
info_type=question,
|
152
|
+
)
|
150
153
|
|
151
154
|
|
152
155
|
def submit_question(
|
@@ -157,7 +160,7 @@ def submit_question(
|
|
157
160
|
responses: dict,
|
158
161
|
score: dict,
|
159
162
|
base_url: str = "https://engr-131-api.eastus.cloudapp.azure.com/",
|
160
|
-
):
|
163
|
+
) -> Response:
|
161
164
|
url = base_url + "/submit-question"
|
162
165
|
|
163
166
|
payload = {
|
@@ -174,7 +177,7 @@ def submit_question(
|
|
174
177
|
return res
|
175
178
|
|
176
179
|
|
177
|
-
# TODO:
|
180
|
+
# TODO: Refine
|
178
181
|
def verify_server(
|
179
182
|
jhub_user: Optional[str] = None,
|
180
183
|
url: str = "https://engr-131-api.eastus.cloudapp.azure.com/",
|
@@ -183,43 +186,3 @@ def verify_server(
|
|
183
186
|
res = requests.get(url, params=params)
|
184
187
|
message = f"status code: {res.status_code}"
|
185
188
|
return message
|
186
|
-
|
187
|
-
|
188
|
-
# TODO: implement function; or maybe not?
|
189
|
-
# At least improve other one
|
190
|
-
def score_question_improved(
|
191
|
-
week: str,
|
192
|
-
assignment_category: str,
|
193
|
-
term: str = "winter_2025",
|
194
|
-
base_url: str = "https://engr-131-api.eastus.cloudapp.azure.com",
|
195
|
-
) -> None:
|
196
|
-
url = base_url + "/live-scorer"
|
197
|
-
|
198
|
-
responses = ensure_responses()
|
199
|
-
|
200
|
-
payload: dict[str, Any] = {
|
201
|
-
"student_email": f'{responses["jhub_user"]}@drexel.edu',
|
202
|
-
"term": term,
|
203
|
-
"week": week,
|
204
|
-
"assignment": assignment_category,
|
205
|
-
"question": f'_{responses["assignment"]}',
|
206
|
-
"responses": responses,
|
207
|
-
}
|
208
|
-
|
209
|
-
res = requests.post(url, json=payload, auth=HTTPBasicAuth("student", "capture"))
|
210
|
-
|
211
|
-
res_data = res.json()
|
212
|
-
# max_points, points_earned = res_data["max_points"], res_data["points_earned"]
|
213
|
-
# log_variable(
|
214
|
-
# assignment_name=responses["assignment"],
|
215
|
-
# value=f"{points_earned}, {max_points}",
|
216
|
-
# info_type="score",
|
217
|
-
# )
|
218
|
-
|
219
|
-
# res_data is now dict[str, tuple[float, float]]
|
220
|
-
for question, (points_earned, max_points) in res_data.items():
|
221
|
-
log_variable(
|
222
|
-
assignment_name=responses["assignment"],
|
223
|
-
value=f"{points_earned}, {max_points}",
|
224
|
-
info_type=question,
|
225
|
-
)
|
pykubegrader/utils.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import random
|
2
|
-
from typing import Tuple
|
2
|
+
from typing import Optional, Tuple
|
3
3
|
|
4
4
|
import panel as pn
|
5
5
|
|
@@ -8,7 +8,7 @@ def list_of_lists(options: list) -> bool:
|
|
8
8
|
return all(isinstance(elem, list) for elem in options)
|
9
9
|
|
10
10
|
|
11
|
-
def shuffle_options(options, seed: int):
|
11
|
+
def shuffle_options(options: list[Optional[str]], seed: int) -> list[Optional[str]]:
|
12
12
|
random.seed(seed)
|
13
13
|
random.shuffle(options)
|
14
14
|
|
pykubegrader/validate.py
CHANGED
@@ -19,18 +19,20 @@ def validate_logfile(
|
|
19
19
|
filepath: str,
|
20
20
|
assignment_id: str,
|
21
21
|
question_max_scores: dict[int, int],
|
22
|
-
free_response_questions=0,
|
23
|
-
username="student",
|
24
|
-
password="capture",
|
25
|
-
base_url="https://engr-131-api.eastus.cloudapp.azure.com",
|
22
|
+
free_response_questions: int = 0,
|
23
|
+
username: str = "student",
|
24
|
+
password: str = "capture",
|
25
|
+
base_url: str = "https://engr-131-api.eastus.cloudapp.azure.com",
|
26
|
+
key_box=None,
|
26
27
|
) -> None:
|
27
28
|
login_data = {
|
28
29
|
"username": username,
|
29
30
|
"password": password,
|
30
31
|
}
|
31
32
|
|
32
|
-
|
33
|
-
|
33
|
+
if key_box is None:
|
34
|
+
# Generate box from private and public keys
|
35
|
+
key_box = generate_keys()
|
34
36
|
|
35
37
|
decrypted_log, log_reduced = read_logfile(filepath, key_box)
|
36
38
|
|
@@ -225,7 +227,7 @@ def validate_logfile(
|
|
225
227
|
submission_message(response)
|
226
228
|
|
227
229
|
|
228
|
-
def read_logfile(filepath, key_box=None) -> tuple[list[str], list[str]]:
|
230
|
+
def read_logfile(filepath: str, key_box=None) -> tuple[list[str], list[str]]:
|
229
231
|
if key_box is None:
|
230
232
|
key_box = generate_keys()
|
231
233
|
|
@@ -307,7 +309,7 @@ def get_last_entry(data: list[str], field_name: str) -> str:
|
|
307
309
|
return ""
|
308
310
|
|
309
311
|
|
310
|
-
def submission_message(response) -> None:
|
312
|
+
def submission_message(response: requests.Response) -> None:
|
311
313
|
if response.status_code == 200:
|
312
314
|
print("Data successfully uploaded to the server")
|
313
315
|
print(response.text)
|
@@ -326,7 +328,7 @@ def submission_message(response) -> None:
|
|
326
328
|
print("results.json was not present")
|
327
329
|
|
328
330
|
|
329
|
-
def verify_login(login_data, login_url):
|
331
|
+
def verify_login(login_data: dict[str, str], login_url: str) -> None:
|
330
332
|
login_response = requests.post(
|
331
333
|
login_url, auth=HTTPBasicAuth(login_data["username"], login_data["password"])
|
332
334
|
)
|
pykubegrader/widgets/__init__.py
CHANGED
@@ -1,19 +1 @@
|
|
1
|
-
# Auto-generated __init__.py
|
2
1
|
|
3
|
-
from . import (
|
4
|
-
multiple_choice,
|
5
|
-
reading_question,
|
6
|
-
select_many,
|
7
|
-
student_info,
|
8
|
-
true_false,
|
9
|
-
types_question,
|
10
|
-
)
|
11
|
-
|
12
|
-
__all__ = [
|
13
|
-
"select_many",
|
14
|
-
"multiple_choice",
|
15
|
-
"true_false",
|
16
|
-
"reading_question",
|
17
|
-
"student_info",
|
18
|
-
"types_question",
|
19
|
-
]
|
@@ -13,7 +13,7 @@ pn.extension(design="material", global_css=[drexel_colors], raw_css=[raw_css])
|
|
13
13
|
#
|
14
14
|
|
15
15
|
|
16
|
-
def
|
16
|
+
def TFStyle(
|
17
17
|
descriptions: List[str],
|
18
18
|
options: List[str] | List[List[str]],
|
19
19
|
initial_vals: List[str],
|
@@ -71,7 +71,7 @@ class TFQuestion(SelectQuestion):
|
|
71
71
|
def __init__(
|
72
72
|
self,
|
73
73
|
title="Select if the statement is True or False",
|
74
|
-
style=
|
74
|
+
style=TFStyle,
|
75
75
|
question_number=2,
|
76
76
|
keys=["MC1", "MC2", "MC3", "MC4"],
|
77
77
|
options=None,
|
@@ -3,7 +3,7 @@ from typing import Callable, Tuple
|
|
3
3
|
|
4
4
|
import panel as pn
|
5
5
|
|
6
|
-
from ..telemetry import ensure_responses, update_responses
|
6
|
+
from ..telemetry import ensure_responses, score_question, update_responses
|
7
7
|
from ..utils import shuffle_questions
|
8
8
|
from ..widgets.style import drexel_colors, raw_css
|
9
9
|
|
@@ -104,6 +104,8 @@ class MultiSelectQuestion:
|
|
104
104
|
|
105
105
|
self.record_responses(responses_flat)
|
106
106
|
|
107
|
+
score_question() # Debugging; update later
|
108
|
+
|
107
109
|
def record_responses(self, responses_flat: list[bool]) -> None:
|
108
110
|
for key, value in zip(self.keys, responses_flat):
|
109
111
|
update_responses(key, value)
|
@@ -3,7 +3,7 @@ from typing import Optional
|
|
3
3
|
|
4
4
|
import panel as pn
|
5
5
|
|
6
|
-
from ..telemetry import ensure_responses, update_responses
|
6
|
+
from ..telemetry import ensure_responses, score_question, update_responses
|
7
7
|
from ..utils import shuffle_options
|
8
8
|
|
9
9
|
|
@@ -162,6 +162,8 @@ class ReadingPython:
|
|
162
162
|
i += 1
|
163
163
|
update_responses(f"q{self.question_number}_{i}", exec_val)
|
164
164
|
|
165
|
+
score_question() # Debugging; update later
|
166
|
+
|
165
167
|
print("Responses recorded successfully")
|
166
168
|
|
167
169
|
def show(self):
|
@@ -3,7 +3,7 @@ from typing import Callable, Tuple
|
|
3
3
|
|
4
4
|
import panel as pn
|
5
5
|
|
6
|
-
from ..telemetry import ensure_responses, update_responses
|
6
|
+
from ..telemetry import ensure_responses, score_question, update_responses
|
7
7
|
from ..utils import shuffle_questions
|
8
8
|
from ..widgets.style import drexel_colors
|
9
9
|
|
@@ -71,6 +71,8 @@ class SelectQuestion:
|
|
71
71
|
for key, value in selections.items():
|
72
72
|
update_responses(key, value)
|
73
73
|
|
74
|
+
score_question() # Debugging; update later
|
75
|
+
|
74
76
|
# Temporarily change button text to indicate submission
|
75
77
|
self.submit_button.name = "Responses Submitted"
|
76
78
|
time.sleep(1)
|
@@ -1,27 +0,0 @@
|
|
1
|
-
pykubegrader/__init__.py,sha256=AoAkdfIjDDZGWLlsIRENNq06L9h46kDGBIE8vRmsCfg,311
|
2
|
-
pykubegrader/initialize.py,sha256=t3iSdeIcndfY8LoHBVUEZfTW6sUWHyeFLirKo4GwSQE,3328
|
3
|
-
pykubegrader/telemetry.py,sha256=IVQISKPfJX7IJ4tCG9AOO36UlTtbVZv2rZnrv3-ZSIc,5994
|
4
|
-
pykubegrader/utils.py,sha256=dKw6SyRYU3DWRgD3xER7wq-C9e1daWPkqr901LpcwiQ,642
|
5
|
-
pykubegrader/validate.py,sha256=vEdNN386yFloDRcjMDrTAqfBmeCXGcDPNH_rLZScIm8,10945
|
6
|
-
pykubegrader/build/api_notebook_builder.py,sha256=GVi6hupfQaWeFMv6Bdela3FTRHvOQYXPIcICnkaLhgA,20119
|
7
|
-
pykubegrader/build/build_folder.py,sha256=_mVEUiPwOPARBHkucAOw2bP8XzmspfL6B4-Ic_Ey3I0,66493
|
8
|
-
pykubegrader/log_parser/parse.ipynb,sha256=F3ZWi5_AOxEnSihY0VBz4jjqo0__GggjRgFvS0QCHTg,10611
|
9
|
-
pykubegrader/log_parser/parse.py,sha256=aON6tWj0dFJcYR9GmzXWfmZ4_t8LU1FTq6vbWCePgRs,6987
|
10
|
-
pykubegrader/widgets/__init__.py,sha256=s3ky3eJDa1RedFVdpKxmqv6mHBYpOSL9Z6qThSH9cbs,303
|
11
|
-
pykubegrader/widgets/multiple_choice.py,sha256=NjD3-uXSnibpUQ0mO3hRp_O-rynFyl0Dz6IXE4tnCRI,2078
|
12
|
-
pykubegrader/widgets/reading_question.py,sha256=y30_swHwzH8LrT8deWTnxctAAmR8BSxTlXAqMgUrAT4,3031
|
13
|
-
pykubegrader/widgets/select_many.py,sha256=l7YQ8QT5k71j36KC1f5LmKIAX2bXpvMDGc6nqIJ1PeQ,4116
|
14
|
-
pykubegrader/widgets/student_info.py,sha256=xhQgKehk1r5e6N_hnjAIovLdPvQju6ZqQTOiPG0aevg,3568
|
15
|
-
pykubegrader/widgets/style.py,sha256=fVBMYy_a6Yoz21avNpiORWC3f5FD-OrVpaZ3npmunvs,1656
|
16
|
-
pykubegrader/widgets/true_false.py,sha256=pE2FjvX6WQ-Z423N40nTWMtudtGS7LiNXZ5dERk6uWs,2823
|
17
|
-
pykubegrader/widgets/types_question.py,sha256=kZdRRXyFzOtYTmGdC7XWb_2oaxqg1WSuLcQn_sTj6Qc,2300
|
18
|
-
pykubegrader/widgets_base/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
19
|
-
pykubegrader/widgets_base/multi_select.py,sha256=jzuK_1eLyhDOzR1jragLRBf_aO8IEaoixfozxBeMBs0,3975
|
20
|
-
pykubegrader/widgets_base/reading.py,sha256=4uTLmlPzCwxVzufFhPjM7W19uMGguRb6y4eAV3x-zAc,5314
|
21
|
-
pykubegrader/widgets_base/select.py,sha256=b5mmd-Cl1A2T2ePZ20-KLVyvP3bzvzYX36n3lMcrcFM,2456
|
22
|
-
PyKubeGrader-0.1.22.dist-info/LICENSE.txt,sha256=YTp-Ewc8Kems8PJEE27KnBPFnZSxoWvSg7nnknzPyYw,1546
|
23
|
-
PyKubeGrader-0.1.22.dist-info/METADATA,sha256=AeAp-atdN0IzDgWASkN7M-AtvFD4j2GvCE3Q_aGfCTQ,2665
|
24
|
-
PyKubeGrader-0.1.22.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
25
|
-
PyKubeGrader-0.1.22.dist-info/entry_points.txt,sha256=Kd4Bh-i3hc4qlnLU1p0nc8yPw9cC5AQGOtkk2eLGnQw,78
|
26
|
-
PyKubeGrader-0.1.22.dist-info/top_level.txt,sha256=e550Klfze6higFxER1V62fnGOcIgiKRbsrl9CC4UdtQ,13
|
27
|
-
PyKubeGrader-0.1.22.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|