QuizGenerator 0.1.0__py3-none-any.whl

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 (44) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +27 -0
  3. QuizGenerator/__main__.py +7 -0
  4. QuizGenerator/canvas/__init__.py +13 -0
  5. QuizGenerator/canvas/canvas_interface.py +622 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1809 -0
  9. QuizGenerator/generate.py +362 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +480 -0
  12. QuizGenerator/mixins.py +539 -0
  13. QuizGenerator/performance.py +202 -0
  14. QuizGenerator/premade_questions/__init__.py +0 -0
  15. QuizGenerator/premade_questions/basic.py +103 -0
  16. QuizGenerator/premade_questions/cst334/__init__.py +1 -0
  17. QuizGenerator/premade_questions/cst334/languages.py +395 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1398 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +396 -0
  22. QuizGenerator/premade_questions/cst334/process.py +649 -0
  23. QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  24. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
  25. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
  26. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
  27. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
  28. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
  29. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
  30. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
  31. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
  32. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  33. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1264 -0
  34. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  35. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  36. QuizGenerator/qrcode_generator.py +293 -0
  37. QuizGenerator/question.py +657 -0
  38. QuizGenerator/quiz.py +468 -0
  39. QuizGenerator/typst_utils.py +113 -0
  40. quizgenerator-0.1.0.dist-info/METADATA +263 -0
  41. quizgenerator-0.1.0.dist-info/RECORD +44 -0
  42. quizgenerator-0.1.0.dist-info/WHEEL +4 -0
  43. quizgenerator-0.1.0.dist-info/entry_points.txt +2 -0
  44. quizgenerator-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,362 @@
1
+ #!env python
2
+ import argparse
3
+ import os
4
+ import random
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+ from dotenv import load_dotenv
10
+ from QuizGenerator.canvas.canvas_interface import CanvasInterface, CanvasCourse
11
+
12
+ from QuizGenerator.quiz import Quiz
13
+
14
+ # Load environment variables from ~/.env
15
+ load_dotenv(Path.home() / '.env')
16
+
17
+ import logging
18
+ log = logging.getLogger(__name__)
19
+
20
+ from QuizGenerator.performance import PerformanceTracker
21
+
22
+
23
+ def parse_args():
24
+ parser = argparse.ArgumentParser()
25
+
26
+ parser.add_argument("--prod", action="store_true")
27
+ parser.add_argument("--course_id", type=int)
28
+
29
+ parser.add_argument("--quiz_yaml", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "example_files/exam_generation.yaml"))
30
+ parser.add_argument("--num_canvas", default=0, type=int)
31
+ parser.add_argument("--num_pdfs", default=0, type=int)
32
+ parser.add_argument("--seed", type=int, default=None,
33
+ help="Random seed for quiz generation (default: None for random)")
34
+ parser.add_argument("--typst", action="store_true",
35
+ help="Use Typst instead of LaTeX for PDF generation")
36
+ parser.add_argument("--typst-measurement", action="store_true",
37
+ help="Use Typst to measure question heights for optimal bin-packing (requires Typst)")
38
+ parser.add_argument("--delete-assignment-group", action="store_true",
39
+ help="Delete existing assignment group before uploading new quizzes")
40
+
41
+ subparsers = parser.add_subparsers(dest='command')
42
+ test_parser = subparsers.add_parser("TEST")
43
+
44
+
45
+ args = parser.parse_args()
46
+
47
+ if args.num_canvas > 0 and args.course_id is None:
48
+ log.error("Must provide course_id when pushing to canvas")
49
+ exit(8)
50
+
51
+ return args
52
+
53
+
54
+ def test():
55
+ log.info("Running test...")
56
+
57
+ # Load the CST463 quiz configuration to test vector questions
58
+ quiz_yaml = os.path.join(os.path.dirname(os.path.abspath(__file__)), "example_files/cst463.yaml")
59
+
60
+ # Generate a quiz
61
+ quizzes = Quiz.from_yaml(quiz_yaml)
62
+
63
+ # Find a vector question to test
64
+ from QuizGenerator.question import QuestionRegistry
65
+
66
+ print("="*60)
67
+ print("CANVAS ANSWER DUPLICATION TEST")
68
+ print("="*60)
69
+
70
+ # Test multiple question types to find which ones use VECTOR variable kind
71
+ question_types = ["VectorAddition", "VectorDotProduct", "VectorMagnitude", "DerivativeBasic", "DerivativeChain"]
72
+
73
+ for question_type in question_types:
74
+ print(f"\n" + "="*20 + f" TESTING {question_type} " + "="*20)
75
+ question = QuestionRegistry.create(question_type, name=f"Test {question_type}", points_value=1)
76
+
77
+ print(f"Question answers: {len(question.answers)} answer objects")
78
+
79
+ # Show all individual answers and their Canvas representations
80
+ for key, answer in question.answers.items():
81
+ canvas_answers = answer.get_for_canvas()
82
+ print(f"\nAnswer key '{key}':")
83
+ print(f" Variable kind: {answer.variable_kind}")
84
+ print(f" Value: {answer.value}")
85
+ print(f" Canvas entries: {len(canvas_answers)}")
86
+ for i, canvas_answer in enumerate(canvas_answers):
87
+ print(f" {i+1}: '{canvas_answer['answer_text']}'")
88
+
89
+ # Show duplicates if they exist
90
+ texts = [ca['answer_text'] for ca in canvas_answers]
91
+ unique_texts = set(texts)
92
+ duplicates = len(texts) - len(unique_texts)
93
+ if duplicates > 0:
94
+ print(f" *** {duplicates} DUPLICATE ENTRIES FOUND ***")
95
+ for text in sorted(unique_texts):
96
+ count = texts.count(text)
97
+ if count > 1:
98
+ print(f" '{text}' appears {count} times")
99
+
100
+ # Check for phantom blank_ids that aren't displayed
101
+ print(f"\n" + "="*20 + " PHANTOM BLANK_ID CHECK " + "="*20)
102
+
103
+ for question_type in ["DerivativeBasic", "DerivativeChain"]:
104
+ print(f"\n--- {question_type} ---")
105
+ question = QuestionRegistry.create(question_type, name=f"Test {question_type}", points_value=1)
106
+
107
+ # Get the question body to see what blank_ids are actually displayed
108
+ body = question.get_body()
109
+ body_html = body.render("html")
110
+
111
+ print(f" HTML body preview:")
112
+ print(f" {body_html[:200]}...")
113
+
114
+ # Extract blank_ids from the HTML using regex
115
+ import re
116
+ displayed_blank_ids = set(re.findall(r'name="([^"]*)"', body_html))
117
+
118
+ # Get all blank_ids from the answers
119
+ question_type_enum, canvas_answers = question.get_answers()
120
+ all_blank_ids = set(answer['blank_id'] for answer in canvas_answers)
121
+
122
+ print(f" Total answers in self.answers: {len(question.answers)}")
123
+ print(f" Total Canvas answer entries: {len(canvas_answers)}")
124
+ print(f" Unique blank_ids in Canvas answers: {len(all_blank_ids)}")
125
+ print(f" Blank_ids displayed in HTML: {len(displayed_blank_ids)}")
126
+
127
+ # Find phantom blank_ids
128
+ phantom_blank_ids = all_blank_ids - displayed_blank_ids
129
+ if phantom_blank_ids:
130
+ print(f" *** PHANTOM BLANK_IDS FOUND: {phantom_blank_ids} ***")
131
+ for phantom_id in phantom_blank_ids:
132
+ phantom_answers = [a for a in canvas_answers if a['blank_id'] == phantom_id]
133
+ print(f" '{phantom_id}': {len(phantom_answers)} entries not displayed")
134
+ else:
135
+ print(f" No phantom blank_ids found")
136
+
137
+ # Show what blank_ids are actually displayed
138
+ print(f" Displayed blank_ids: {sorted(displayed_blank_ids)}")
139
+ print(f" All answer blank_ids: {sorted(all_blank_ids)}")
140
+
141
+ # Now create a synthetic test to demonstrate the VECTOR bug
142
+ print(f"\n" + "="*20 + " SYNTHETIC VECTOR TEST " + "="*20)
143
+ from QuizGenerator.misc import Answer
144
+
145
+ # Create a synthetic answer with VECTOR variable kind to demonstrate the bug
146
+ vector_answer = Answer(
147
+ key="test_vector",
148
+ value=[1, 2, 3], # 3D vector
149
+ variable_kind=Answer.VariableKind.VECTOR
150
+ )
151
+
152
+ canvas_answers = vector_answer.get_for_canvas()
153
+ print(f"Synthetic VECTOR answer:")
154
+ print(f" Value: {vector_answer.value}")
155
+ print(f" Canvas entries: {len(canvas_answers)}")
156
+ # Only show first few to save space
157
+ for i, canvas_answer in enumerate(canvas_answers[:5]):
158
+ print(f" {i+1}: '{canvas_answer['answer_text']}'")
159
+ if len(canvas_answers) > 5:
160
+ print(f" ... and {len(canvas_answers) - 5} more entries")
161
+
162
+ print("\n" + "="*60)
163
+ print("TEST COMPLETE")
164
+ print("="*60)
165
+
166
+
167
+ def generate_latex(latex_text, remove_previous=False):
168
+
169
+ if remove_previous:
170
+ if os.path.exists('out'): shutil.rmtree('out')
171
+
172
+ tmp_tex = tempfile.NamedTemporaryFile('w')
173
+
174
+ tmp_tex.write(latex_text)
175
+
176
+ tmp_tex.flush()
177
+ shutil.copy(f"{tmp_tex.name}", "debug.tex")
178
+ p = subprocess.Popen(
179
+ f"latexmk -pdf -output-directory={os.path.join(os.getcwd(), 'out')} {tmp_tex.name}",
180
+ shell=True,
181
+ stdout=subprocess.PIPE,
182
+ stderr=subprocess.PIPE)
183
+ try:
184
+ p.wait(30)
185
+ except subprocess.TimeoutExpired:
186
+ logging.error("Latex Compile timed out")
187
+ p.kill()
188
+ tmp_tex.close()
189
+ return
190
+ proc = subprocess.Popen(
191
+ f"latexmk -c {tmp_tex.name} -output-directory={os.path.join(os.getcwd(), 'out')}",
192
+ shell=True,
193
+ stdout=subprocess.PIPE,
194
+ stderr=subprocess.PIPE
195
+ )
196
+ proc.wait(timeout=30)
197
+ tmp_tex.close()
198
+
199
+
200
+ def generate_typst(typst_text, remove_previous=False):
201
+ """
202
+ Generate PDF from Typst source code.
203
+
204
+ Similar to generate_latex, but uses typst compiler instead of latexmk.
205
+ """
206
+ if remove_previous:
207
+ if os.path.exists('out'):
208
+ shutil.rmtree('out')
209
+
210
+ # Ensure output directory exists
211
+ os.makedirs('out', exist_ok=True)
212
+
213
+ # Create temporary Typst file
214
+ tmp_typ = tempfile.NamedTemporaryFile('w', suffix='.typ', delete=False)
215
+
216
+ try:
217
+ tmp_typ.write(typst_text)
218
+ tmp_typ.flush()
219
+ tmp_typ.close()
220
+
221
+ # Save debug copy
222
+ shutil.copy(tmp_typ.name, "debug.typ")
223
+
224
+ # Compile with typst
225
+ output_pdf = os.path.join(os.getcwd(), 'out', os.path.basename(tmp_typ.name).replace('.typ', '.pdf'))
226
+
227
+ # Use --root to set the filesystem root so absolute paths work correctly
228
+ p = subprocess.Popen(
229
+ ['typst', 'compile', '--root', '/', tmp_typ.name, output_pdf],
230
+ stdout=subprocess.PIPE,
231
+ stderr=subprocess.PIPE
232
+ )
233
+
234
+ try:
235
+ p.wait(30)
236
+ if p.returncode != 0:
237
+ stderr = p.stderr.read().decode('utf-8')
238
+ log.error(f"Typst compilation failed: {stderr}")
239
+ except subprocess.TimeoutExpired:
240
+ log.error("Typst compile timed out")
241
+ p.kill()
242
+
243
+ finally:
244
+ # Clean up temp file
245
+ if os.path.exists(tmp_typ.name):
246
+ os.unlink(tmp_typ.name)
247
+
248
+ def generate_quiz(
249
+ path_to_quiz_yaml,
250
+ num_pdfs=0,
251
+ num_canvas=0,
252
+ use_prod=False,
253
+ course_id=None,
254
+ delete_assignment_group=False,
255
+ use_typst=False,
256
+ use_typst_measurement=False,
257
+ base_seed=None
258
+ ):
259
+
260
+ quizzes = Quiz.from_yaml(path_to_quiz_yaml)
261
+
262
+ # Handle Canvas uploads with shared assignment group
263
+ if num_canvas > 0:
264
+ canvas_interface = CanvasInterface(prod=use_prod)
265
+ canvas_course = canvas_interface.get_course(course_id=course_id)
266
+
267
+ # Create assignment group once, with delete flag if specified
268
+ assignment_group = canvas_course.create_assignment_group(
269
+ name="dev",
270
+ delete_existing=delete_assignment_group
271
+ )
272
+
273
+ log.info(f"Using assignment group '{assignment_group.name}' for all quizzes")
274
+
275
+ for quiz in quizzes:
276
+
277
+ for i in range(num_pdfs):
278
+ log.debug(f"Generating PDF {i+1}/{num_pdfs}")
279
+ # If base_seed is provided, use it with an offset for each PDF
280
+ # Otherwise generate a random seed for this PDF
281
+ if base_seed is not None:
282
+ pdf_seed = base_seed + (i * 1000) # Large gap to avoid overlap with rng_seed_offset
283
+ else:
284
+ pdf_seed = random.randint(0, 1_000_000)
285
+
286
+ log.info(f"Generating PDF {i+1} with seed: {pdf_seed}")
287
+
288
+ if use_typst:
289
+ # Generate using Typst
290
+ typst_text = quiz.get_quiz(rng_seed=pdf_seed, use_typst_measurement=use_typst_measurement).render("typst")
291
+ generate_typst(typst_text, remove_previous=(i==0))
292
+ else:
293
+ # Generate using LaTeX (default)
294
+ latex_text = quiz.get_quiz(rng_seed=pdf_seed, use_typst_measurement=use_typst_measurement).render_latex()
295
+ generate_latex(latex_text, remove_previous=(i==0))
296
+
297
+ if num_canvas > 0:
298
+ canvas_course.push_quiz_to_canvas(
299
+ quiz,
300
+ num_canvas,
301
+ title=quiz.name,
302
+ is_practice=quiz.practice,
303
+ assignment_group=assignment_group
304
+ )
305
+
306
+ quiz.describe()
307
+
308
+ # Generate performance report if Canvas questions were generated
309
+ if num_canvas > 0:
310
+ print("\n" + "="*80)
311
+ print("PERFORMANCE ANALYSIS REPORT")
312
+ print("="*80)
313
+ PerformanceTracker.report_summary(min_duration=0.01) # Show operations taking >10ms
314
+
315
+ # Show detailed breakdown for slowest operations
316
+ print("\n" + "="*60)
317
+ print("DETAILED TIMING BREAKDOWN")
318
+ print("="*60)
319
+
320
+ slow_operations = ["canvas_prepare_question", "canvas_api_upload", "ast_render_body", "question_body"]
321
+ for op in slow_operations:
322
+ metrics = PerformanceTracker.get_metrics_by_operation(op)
323
+ if metrics:
324
+ print(f"\n{op.upper()}:")
325
+ # Show stats by question type
326
+ by_type = {}
327
+ for m in metrics:
328
+ qtype = m.question_type or "unknown"
329
+ if qtype not in by_type:
330
+ by_type[qtype] = []
331
+ by_type[qtype].append(m.duration)
332
+
333
+ for qtype, durations in by_type.items():
334
+ avg = sum(durations) / len(durations)
335
+ print(f" {qtype}: {len(durations)} calls, avg {avg:.3f}s (range: {min(durations):.3f}s - {max(durations):.3f}s)")
336
+
337
+ def main():
338
+
339
+ args = parse_args()
340
+
341
+ if args.command == "TEST":
342
+ test()
343
+ return
344
+
345
+ # Clear any previous metrics
346
+ PerformanceTracker.clear_metrics()
347
+
348
+ generate_quiz(
349
+ args.quiz_yaml,
350
+ num_pdfs=args.num_pdfs,
351
+ num_canvas=args.num_canvas,
352
+ use_prod=args.prod,
353
+ course_id=args.course_id,
354
+ delete_assignment_group=getattr(args, 'delete_assignment_group', False),
355
+ use_typst=getattr(args, 'typst', False),
356
+ use_typst_measurement=getattr(args, 'typst_measurement', False),
357
+ base_seed=getattr(args, 'seed', None)
358
+ )
359
+
360
+
361
+ if __name__ == "__main__":
362
+ main()
@@ -0,0 +1,55 @@
1
+ version: 1
2
+ disable_existing_loggers: false
3
+
4
+ formatters:
5
+ standard:
6
+ format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
7
+ datefmt: '%Y-%m-%d %H:%M:%S'
8
+
9
+ detailed:
10
+ format: '%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s:%(lineno)d - %(message)s'
11
+ datefmt: '%Y-%m-%d %H:%M:%S'
12
+
13
+ simple:
14
+ format: '%(levelname)s - %(message)s'
15
+
16
+ handlers:
17
+ console:
18
+ class: logging.StreamHandler
19
+ level: INFO
20
+ formatter: standard
21
+ stream: ext://sys.stdout
22
+
23
+ file:
24
+ class: logging.FileHandler
25
+ level: INFO
26
+ formatter: detailed
27
+ filename: ${LOG_FILE:-teachingtools.log}
28
+ mode: a
29
+
30
+ error_file:
31
+ class: logging.FileHandler
32
+ level: ERROR
33
+ formatter: detailed
34
+ filename: ${ERROR_LOG_FILE:-teachingtools_errors.log}
35
+ mode: a
36
+
37
+ loggers:
38
+ QuizGenerator:
39
+ level: INFO
40
+ handlers: [console, file]
41
+ propagate: false
42
+
43
+ lms_interface:
44
+ level: INFO
45
+ handlers: [console, file]
46
+ propagate: false
47
+
48
+ canvasapi:
49
+ level: WARNING
50
+ handlers: [console]
51
+ propagate: false
52
+
53
+ root:
54
+ level: INFO
55
+ handlers: [console, file, error_file]