QuizGenerator 0.4.2__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 (52) 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 +627 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1955 -0
  9. QuizGenerator/generate.py +253 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +579 -0
  12. QuizGenerator/mixins.py +548 -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 +391 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1400 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +451 -0
  22. QuizGenerator/premade_questions/cst334/process.py +648 -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/models/__init__.py +0 -0
  33. QuizGenerator/premade_questions/cst463/models/attention.py +192 -0
  34. QuizGenerator/premade_questions/cst463/models/cnns.py +186 -0
  35. QuizGenerator/premade_questions/cst463/models/matrices.py +24 -0
  36. QuizGenerator/premade_questions/cst463/models/rnns.py +202 -0
  37. QuizGenerator/premade_questions/cst463/models/text.py +203 -0
  38. QuizGenerator/premade_questions/cst463/models/weight_counting.py +227 -0
  39. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  40. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1314 -0
  41. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  42. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  43. QuizGenerator/qrcode_generator.py +293 -0
  44. QuizGenerator/question.py +715 -0
  45. QuizGenerator/quiz.py +467 -0
  46. QuizGenerator/regenerate.py +472 -0
  47. QuizGenerator/typst_utils.py +113 -0
  48. quizgenerator-0.4.2.dist-info/METADATA +265 -0
  49. quizgenerator-0.4.2.dist-info/RECORD +52 -0
  50. quizgenerator-0.4.2.dist-info/WHEEL +4 -0
  51. quizgenerator-0.4.2.dist-info/entry_points.txt +3 -0
  52. quizgenerator-0.4.2.dist-info/licenses/LICENSE +674 -0
QuizGenerator/quiz.py ADDED
@@ -0,0 +1,467 @@
1
+ #!env python
2
+ from __future__ import annotations
3
+
4
+ import collections
5
+ import itertools
6
+ import logging
7
+ import os.path
8
+ import random
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from datetime import datetime
13
+ from typing import List, Dict, Optional
14
+
15
+ import yaml
16
+
17
+ from QuizGenerator.contentast import ContentAST
18
+ from QuizGenerator.question import Question, QuestionRegistry, QuestionGroup
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ class Quiz:
24
+ """
25
+ A quiz object that will build up questions and output them in a range of formats (hopefully)
26
+ It should be that a single quiz object can contain multiples -- essentially it builds up from the questions and then can generate a variety of questions.
27
+ """
28
+
29
+ INTEREST_THRESHOLD = 1.0
30
+
31
+ def __init__(self, name, questions: List[dict|Question], practice, *args, **kwargs):
32
+ self.name = name
33
+ self.questions = questions
34
+ self.instructions = kwargs.get("instructions", "")
35
+
36
+ # Parse description with ContentAST if provided
37
+ raw_description = kwargs.get("description", None)
38
+ if raw_description:
39
+ # Create a ContentAST document from the description text
40
+ desc_doc = ContentAST.Document()
41
+ desc_doc.add_element(ContentAST.Paragraph([raw_description]))
42
+ self.description = desc_doc.render("html")
43
+ else:
44
+ self.description = None
45
+
46
+ self.question_sort_order = None
47
+ self.practice = practice
48
+ self.preserve_order_point_values = set() # Point values that should preserve question order
49
+
50
+ # Plan: right now we just take in questions and then assume they have a score and a "generate" button
51
+
52
+ def __iter__(self):
53
+ def sort_func(q):
54
+ if self.question_sort_order is not None:
55
+ try:
56
+ return (-q.points_value, self.question_sort_order.index(q.topic))
57
+ except ValueError:
58
+ return (-q.points_value, float('inf'))
59
+ return -q.points_value
60
+ return iter(sorted(self.questions, key=sort_func))
61
+
62
+ @classmethod
63
+ def from_yaml(cls, path_to_yaml) -> List[Quiz]:
64
+
65
+ quizes_loaded : List[Quiz] = []
66
+
67
+ with open(path_to_yaml) as fid:
68
+ list_of_exam_dicts = list(yaml.safe_load_all(fid))
69
+
70
+ for exam_dict in list_of_exam_dicts:
71
+ # Load custom question modules if specified (Option 3: Quick-and-dirty approach)
72
+ # Users can add custom question types by importing Python modules in their YAML:
73
+ # custom_modules:
74
+ # - my_custom_questions.scheduling
75
+ # - university_standard_questions
76
+ custom_modules = exam_dict.get("custom_modules", [])
77
+ if custom_modules:
78
+ import importlib
79
+ for module_name in custom_modules:
80
+ try:
81
+ importlib.import_module(module_name)
82
+ log.info(f"Loaded custom question module: {module_name}")
83
+ except ImportError as e:
84
+ log.error(f"Failed to import custom module '{module_name}': {e}")
85
+ raise
86
+
87
+
88
+ # Get general quiz information from the dictionary
89
+ name = exam_dict.get("name", f"Unnamed Exam ({datetime.now().strftime('%a %b %d %I:%M %p')})")
90
+ practice = exam_dict.get("practice", False)
91
+ description = exam_dict.get("description", None)
92
+ sort_order = list(map(lambda t: Question.Topic.from_string(t), exam_dict.get("sort order", [])))
93
+ sort_order = sort_order + list(filter(lambda t: t not in sort_order, Question.Topic))
94
+
95
+ # Load questions from the quiz dictionary
96
+ questions_for_exam = []
97
+ # Track point values where order should be preserved (for layout optimization)
98
+ preserve_order_point_values = set()
99
+
100
+ for question_value, question_definitions in exam_dict["questions"].items():
101
+ # todo: I can also add in "extra credit" and "mix-ins" as other keys to indicate extra credit or questions that can go anywhere
102
+ log.info(f"Parsing {question_value} point questions")
103
+
104
+ # Check for point-value-level config
105
+ point_config = question_definitions.pop("_config", {})
106
+ if point_config.get("preserve_order", False):
107
+ preserve_order_point_values.add(question_value)
108
+ log.info(f" Point value {question_value} will preserve question order")
109
+
110
+ def make_question(q_name, q_data, **kwargs):
111
+ # Build up the kwargs that we're going to pass in
112
+ # todo: this is currently a mess due to legacy things, so before I tell others to use this make it cleaner
113
+ kwargs= {
114
+ "name": q_name,
115
+ "points_value": question_value,
116
+ **q_data.get("kwargs", {}),
117
+ **q_data,
118
+ **kwargs,
119
+ }
120
+
121
+ # If we are passed in a topic then use it, otherwise don't set it which will have it set to a default
122
+ if "topic" in q_data:
123
+ kwargs["topic"] = Question.Topic.from_string(q_data.get("topic", "Misc"))
124
+
125
+ # Add in a default, where if it isn't specified we're going to simply assume it is a text
126
+ question_class = q_data.get("class", "FromText")
127
+
128
+ new_question = QuestionRegistry.create(
129
+ question_class,
130
+ **kwargs
131
+ )
132
+ return new_question
133
+
134
+ for q_name, q_data in question_definitions.items():
135
+ # Set defaults for config
136
+ question_config = {
137
+ "group" : False,
138
+ "num_to_pick" : 1,
139
+ "random_per_student" : False,
140
+ "repeat": 1,
141
+ "topic": "MISC"
142
+ }
143
+
144
+ # Update config if it exists
145
+ question_config.update(
146
+ q_data.get("_config", {})
147
+ )
148
+ q_data.pop("_config", None)
149
+ q_data.pop("pick", None) # todo: don't use this anymore
150
+ q_data.pop("repeat", None) # todo: don't use this anymore
151
+
152
+ # Check if it is a question group
153
+ if question_config["group"]:
154
+
155
+ # todo: Find a way to allow for "num_to_pick" to ensure lack of duplicates when using duplicates.
156
+ # It's probably going to be somewhere in the instantiate and get_attr fields, with "_current_questions"
157
+ # But will require changing how we add concrete questions (but that'll just be everything returns a list
158
+ questions_for_exam.append(
159
+ QuestionGroup(
160
+ questions_in_group=[
161
+ make_question(name, data | {"topic" : question_config["topic"]}) for name, data in q_data.items()
162
+ ],
163
+ pick_once=(not question_config["random_per_student"])
164
+ )
165
+ )
166
+
167
+ else: # Then this is just a single question
168
+ questions_for_exam.extend([
169
+ make_question(
170
+ q_name,
171
+ q_data,
172
+ rng_seed_offset=repeat_number
173
+ )
174
+ for repeat_number in range(question_config["repeat"])
175
+ ])
176
+ log.debug(f"len(questions_for_exam): {len(questions_for_exam)}")
177
+ quiz_from_yaml = cls(name, questions_for_exam, practice, description=description)
178
+ quiz_from_yaml.set_sort_order(sort_order)
179
+ quiz_from_yaml.preserve_order_point_values = preserve_order_point_values
180
+ quizes_loaded.append(quiz_from_yaml)
181
+ return quizes_loaded
182
+
183
+ def _estimate_question_height(self, question, use_typst_measurement=False, **kwargs) -> float:
184
+ """
185
+ Estimate the rendered height of a question for layout optimization.
186
+ Returns height in centimeters.
187
+
188
+ Args:
189
+ question: Question object to measure
190
+ use_typst_measurement: If True, use Typst's layout engine for exact measurement
191
+ **kwargs: Additional arguments passed to question rendering
192
+
193
+ Returns:
194
+ Height in centimeters
195
+ """
196
+ # Try Typst measurement if requested and available
197
+ if use_typst_measurement:
198
+ from QuizGenerator.typst_utils import measure_typst_content, check_typst_available
199
+
200
+ if check_typst_available():
201
+ try:
202
+ # Render question to Typst
203
+ question_ast = question.get_question(**kwargs)
204
+
205
+ # Get just the content body (without the #question wrapper which adds spacing)
206
+ typst_body = question_ast.body.render("typst", **kwargs)
207
+
208
+ # Measure the content
209
+ measured_height = measure_typst_content(typst_body, page_width_cm=18.0)
210
+
211
+ if measured_height is not None:
212
+ # Add base height for question formatting (header, line, etc.) ~1.5cm
213
+ # Plus the spacing parameter
214
+ total_height = 1.5 + measured_height + question.spacing
215
+ log.debug(f"Typst measurement: {question.name} = {total_height:.2f}cm (content: {measured_height:.2f}cm, spacing: {question.spacing}cm)")
216
+ return total_height
217
+ else:
218
+ log.debug(f"Typst measurement failed for {question.name}, falling back to heuristics")
219
+ except Exception as e:
220
+ log.warning(f"Error during Typst measurement: {e}, falling back to heuristics")
221
+ else:
222
+ log.debug("Typst not available, using heuristic estimation")
223
+
224
+ # Fallback: Use heuristic estimation (original implementation)
225
+ # Base height for question header, borders, and minimal content
226
+ # Each question has: horizontal rule, question number line, and minipage wrapper
227
+ base_height = 1.5 # cm
228
+
229
+ # The spacing parameter directly controls \vspace{} in cm
230
+ spacing_height = question.spacing # cm
231
+
232
+ # Estimate content height by rendering to LaTeX and analyzing structure
233
+ question_ast = question.get_question(**kwargs)
234
+ latex_content = question_ast.render("latex")
235
+
236
+ # Count content that adds height (rough estimates in cm)
237
+ content_height = 0.0
238
+
239
+ # Tables add significant height (~0.5cm per row as rough estimate)
240
+ table_count = latex_content.count('\\begin{tabular}')
241
+ content_height += table_count * 3.0 # Assume ~3cm per table on average
242
+
243
+ # Matrices add height
244
+ matrix_count = latex_content.count('\\begin{') - table_count # Rough matrix count
245
+ content_height += matrix_count * 2.0 # ~2cm per matrix
246
+
247
+ # Code blocks (verbatim) add significant height
248
+ verbatim_count = latex_content.count('\\begin{verbatim}')
249
+ content_height += verbatim_count * 4.0 # ~4cm per code block
250
+
251
+ # Count paragraphs and text blocks (very rough estimate)
252
+ # Each ~500 characters of text ≈ 1cm of height
253
+ char_count = len(latex_content)
254
+ content_height += (char_count / 500.0) * 0.5
255
+
256
+ # Total estimated height
257
+ total_height = base_height + spacing_height + content_height
258
+
259
+ return total_height
260
+
261
+ def _optimize_question_order(self, questions, **kwargs) -> List[Question]:
262
+ """
263
+ Optimize question ordering to minimize PDF length while respecting point-value tiers.
264
+ Uses bin-packing heuristics to reorder questions within each point-value group.
265
+ """
266
+ # Group questions by point value
267
+ from collections import defaultdict
268
+ point_groups = defaultdict(list)
269
+
270
+ for question in questions:
271
+ point_groups[question.points_value].append(question)
272
+
273
+ # Track which point values should preserve order (from config)
274
+ preserve_order_for = kwargs.pop('preserve_order_for', set())
275
+
276
+ # For each point group, estimate heights and apply bin-packing optimization
277
+ optimized_questions = []
278
+ is_first_page = True # Track if we're packing the first page
279
+
280
+ log.debug("Optimizing question order for PDF layout...")
281
+
282
+ for points in sorted(point_groups.keys(), reverse=True):
283
+ group = point_groups[points]
284
+
285
+ # Check if this point tier should preserve order
286
+ if points in preserve_order_for:
287
+ # Sort by topic only (preserve original order)
288
+ group.sort(key=lambda q: self.question_sort_order.index(q.topic))
289
+ optimized_questions.extend(group)
290
+ log.debug(f" {points}pt questions: {len(group)} questions (order preserved by config)")
291
+ # After adding preserved-order questions, we're likely past the first page
292
+ is_first_page = False
293
+ continue
294
+
295
+ # If only 1-2 questions, no optimization needed
296
+ if len(group) <= 2:
297
+ # Still sort by topic for consistency
298
+ group.sort(key=lambda q: self.question_sort_order.index(q.topic))
299
+ optimized_questions.extend(group)
300
+ log.debug(f" {points}pt questions: {len(group)} questions (no optimization needed)")
301
+ is_first_page = False
302
+ continue
303
+
304
+ # Estimate height for each question
305
+ question_heights = [(q, self._estimate_question_height(q, **kwargs)) for q in group]
306
+
307
+ # Sort by height descending to identify large and small questions
308
+ question_heights.sort(key=lambda x: x[1], reverse=True)
309
+
310
+ log.debug(f" Question heights for {points}pt questions:")
311
+ for q, h in question_heights:
312
+ log.debug(f" {q.name}: {h:.1f}cm (spacing={q.spacing}cm)")
313
+
314
+ # Calculate page capacity in centimeters
315
+ # A typical A4 page with margins has ~25cm of usable height
316
+ # After accounting for headers and separators, estimate ~22cm per page
317
+ base_page_capacity = 22.0 # cm
318
+
319
+ # First page has header (title + name line) which takes ~3cm
320
+ first_page_capacity = base_page_capacity - 3.0 if is_first_page else base_page_capacity
321
+
322
+ # Better bin-packing strategy: interleave large and small questions
323
+ # Strategy: Start each page with the largest unplaced question, then fill with smaller ones
324
+ bins = []
325
+ placed = [False] * len(question_heights)
326
+
327
+ while not all(placed):
328
+ # Determine capacity for this page
329
+ page_capacity = first_page_capacity if len(bins) == 0 and is_first_page else base_page_capacity
330
+
331
+ # Find the largest unplaced question to start a new page
332
+ new_page = []
333
+ page_height = 0
334
+
335
+ for i, (question, height) in enumerate(question_heights):
336
+ if not placed[i]:
337
+ new_page.append(question)
338
+ page_height = height
339
+ placed[i] = True
340
+ break
341
+
342
+ # Now try to fill the remaining space with smaller questions
343
+ for i, (question, height) in enumerate(question_heights):
344
+ if not placed[i] and page_height + height <= page_capacity:
345
+ new_page.append(question)
346
+ page_height += height
347
+ placed[i] = True
348
+
349
+ bins.append((new_page, page_height))
350
+
351
+ log.debug(f" {points}pt questions: {len(group)} questions packed into {len(bins)} pages")
352
+ for i, (page_questions, height) in enumerate(bins):
353
+ log.debug(f" Page {i+1}: {height:.1f}cm with {len(page_questions)} questions: {[q.name for q in page_questions]}")
354
+
355
+ # Flatten bins back to ordered list
356
+ for bin_contents, _ in bins:
357
+ optimized_questions.extend(bin_contents)
358
+
359
+ # After packing questions, we're no longer on the first page
360
+ is_first_page = False
361
+
362
+ return optimized_questions
363
+
364
+ def get_quiz(self, **kwargs) -> ContentAST.Document:
365
+ quiz = ContentAST.Document(title=self.name)
366
+
367
+ # Extract master RNG seed (if provided) and remove from kwargs
368
+ master_seed = kwargs.pop('rng_seed', None)
369
+
370
+ # Check if optimization is requested (default: True)
371
+ optimize_layout = kwargs.pop('optimize_layout', True)
372
+
373
+ if optimize_layout:
374
+ # Use optimized ordering, passing preserve_order config
375
+ ordered_questions = self._optimize_question_order(
376
+ self.questions,
377
+ preserve_order_for=self.preserve_order_point_values,
378
+ **kwargs
379
+ )
380
+ else:
381
+ # Use simple ordering by point value and topic
382
+ ordered_questions = sorted(
383
+ self.questions,
384
+ key=lambda q: (-q.points_value, self.question_sort_order.index(q.topic))
385
+ )
386
+
387
+ # Generate questions with sequential numbering for QR codes
388
+ # Use master seed to generate unique per-question seeds
389
+ if master_seed is not None:
390
+ # Create RNG from master seed to generate per-question seeds
391
+ master_rng = random.Random(master_seed)
392
+
393
+ for question_number, question in enumerate(ordered_questions, start=1):
394
+ # Generate a unique seed for this question from the master seed
395
+ if master_seed is not None:
396
+ question_seed = master_rng.randint(0, 2**31 - 1)
397
+ question_ast = question.get_question(rng_seed=question_seed, **kwargs)
398
+ else:
399
+ question_ast = question.get_question(**kwargs)
400
+
401
+ # Add question number to the AST for QR code generation
402
+ question_ast.question_number = question_number
403
+ quiz.add_element(question_ast)
404
+
405
+ return quiz
406
+
407
+ def describe(self):
408
+
409
+ # Print out title
410
+ print(f"Title: {self.name}")
411
+ total_points = sum(map(lambda q: q.points_value, self.questions))
412
+ total_questions = len(self.questions)
413
+
414
+ # Print out overall information
415
+ print(f"{total_points} points total, {total_questions} questions")
416
+
417
+ # Print out the per-value information
418
+ points_counter = collections.Counter([q.points_value for q in self.questions])
419
+ for points in sorted(points_counter.keys(), reverse=True):
420
+ print(f"{points_counter.get(points)} x {points}points")
421
+
422
+ # Either get the sort order or default to the order in the enum class
423
+ sort_order = self.question_sort_order
424
+ if sort_order is None:
425
+ sort_order = Question.Topic
426
+
427
+ # Build per-topic information
428
+
429
+ topic_information = {}
430
+ topic_strings = {}
431
+ for topic in sort_order:
432
+ topic_strings = {"name": topic.name}
433
+
434
+ question_count = len(list(map(lambda q: q.points_value, filter(lambda q: q.topic == topic, self.questions))))
435
+ topic_points = sum(map(lambda q: q.points_value, filter(lambda q: q.topic == topic, self.questions)))
436
+
437
+ # If we have questions add in some states, otherwise mark them as empty
438
+ if question_count != 0:
439
+ topic_strings["count_str"] = f"{question_count} questions ({ 100 * question_count / total_questions:0.1f}%)"
440
+ topic_strings["points_str"] = f"{topic_points:2} points ({ 100 * topic_points / total_points:0.1f}%)"
441
+ else:
442
+ topic_strings["count_str"] = "--"
443
+ topic_strings["points_str"] = "--"
444
+
445
+ topic_information[topic] = topic_strings
446
+
447
+
448
+ # Get padding string lengths
449
+ paddings = collections.defaultdict(lambda: 0)
450
+ for field in topic_strings.keys():
451
+ paddings[field] = max(len(information[field]) for information in topic_information.values())
452
+
453
+ # Print out topics information using the padding
454
+ for topic in sort_order:
455
+ topic_strings = topic_information[topic]
456
+ print(f"{topic_strings['name']:{paddings['name']}} : {topic_strings['count_str']:{paddings['count_str']}} : {topic_strings['points_str']:{paddings['points_str']}}")
457
+
458
+ def set_sort_order(self, sort_order):
459
+ self.question_sort_order = sort_order
460
+
461
+ def main():
462
+ pass
463
+
464
+
465
+ if __name__ == "__main__":
466
+ main()
467
+