QuizGenerator 0.8.1__py3-none-any.whl → 0.10.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 (25) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/canvas/canvas_interface.py +6 -2
  3. QuizGenerator/contentast.py +33 -11
  4. QuizGenerator/generate.py +51 -10
  5. QuizGenerator/logging.yaml +55 -0
  6. QuizGenerator/mixins.py +6 -2
  7. QuizGenerator/premade_questions/basic.py +49 -7
  8. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +92 -82
  9. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +68 -45
  10. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +238 -162
  11. QuizGenerator/premade_questions/cst463/models/attention.py +0 -1
  12. QuizGenerator/premade_questions/cst463/models/cnns.py +0 -1
  13. QuizGenerator/premade_questions/cst463/models/rnns.py +0 -1
  14. QuizGenerator/premade_questions/cst463/models/text.py +0 -1
  15. QuizGenerator/premade_questions/cst463/models/weight_counting.py +20 -1
  16. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +51 -45
  17. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +212 -215
  18. QuizGenerator/qrcode_generator.py +116 -54
  19. QuizGenerator/question.py +168 -23
  20. QuizGenerator/regenerate.py +23 -9
  21. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/METADATA +34 -22
  22. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/RECORD +25 -23
  23. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/WHEEL +0 -0
  24. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/entry_points.txt +0 -0
  25. {quizgenerator-0.8.1.dist-info → quizgenerator-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,5 @@
1
+
2
+
3
+ ## Installation
4
+
5
+ Note, you will need to install `pandoc` prior to running since it is used to convert to HTML (for canvas) and Latex (for PDF)`
@@ -37,8 +37,12 @@ NUM_WORKERS = 4
37
37
 
38
38
 
39
39
  class CanvasInterface:
40
- def __init__(self, *, prod=False):
41
- dotenv.load_dotenv(os.path.join(os.path.expanduser("~"), ".env"))
40
+ def __init__(self, *, prod=False, env_path: str | None = None):
41
+ default_env = os.path.join(os.path.expanduser("~"), ".env")
42
+ if env_path and os.path.exists(env_path):
43
+ dotenv.load_dotenv(env_path)
44
+ elif os.path.exists(default_env):
45
+ dotenv.load_dotenv(default_env)
42
46
 
43
47
  self.prod = prod
44
48
  if self.prod:
@@ -23,6 +23,21 @@ import numpy as np
23
23
 
24
24
  log = logging.getLogger(__name__)
25
25
 
26
+ _PANDOC_OK = None
27
+
28
+
29
+ def _ensure_pandoc() -> bool:
30
+ global _PANDOC_OK
31
+ if _PANDOC_OK is not None:
32
+ return _PANDOC_OK
33
+ try:
34
+ pypandoc.get_pandoc_version()
35
+ _PANDOC_OK = True
36
+ except Exception as exc:
37
+ _PANDOC_OK = False
38
+ log.warning(f"Pandoc not found or unusable: {exc}")
39
+ return _PANDOC_OK
40
+
26
41
 
27
42
  """
28
43
  Content Abstract Syntax Tree - The core content system for quiz generation.
@@ -221,6 +236,9 @@ class Leaf(Element):
221
236
  return html_output.strip()
222
237
 
223
238
  case _:
239
+ if not _ensure_pandoc():
240
+ log.warning(f"Pandoc unavailable; returning raw markdown for {output_format} output.")
241
+ return str_to_convert
224
242
  output = pypandoc.convert_text(
225
243
  str_to_convert,
226
244
  output_format,
@@ -549,16 +567,18 @@ class Question(Container):
549
567
 
550
568
  # Build extra_data dict with regeneration metadata if available
551
569
  extra_data = {}
552
- if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed') and hasattr(
553
- self, 'question_version'
554
- ):
555
- if self.question_class_name and self.generation_seed is not None and self.question_version:
570
+ if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed'):
571
+ if self.question_class_name and self.generation_seed is not None:
556
572
  extra_data['question_type'] = self.question_class_name
557
573
  extra_data['seed'] = self.generation_seed
558
- extra_data['version'] = self.question_version
574
+ if hasattr(self, 'question_version') and self.question_version:
575
+ extra_data['version'] = self.question_version
559
576
  # Include question-specific configuration parameters if available
560
577
  if hasattr(self, 'config_params') and self.config_params:
561
578
  extra_data['config'] = self.config_params
579
+ # Include context-derived extras if available
580
+ if hasattr(self, 'qr_context_extras') and self.qr_context_extras:
581
+ extra_data['context'] = self.qr_context_extras
562
582
 
563
583
  qr_path = QuestionQRCode.generate_qr_pdf(
564
584
  self.question_number,
@@ -615,16 +635,18 @@ class Question(Container):
615
635
 
616
636
  # Build extra_data dict with regeneration metadata if available
617
637
  extra_data = {}
618
- if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed') and hasattr(
619
- self, 'question_version'
620
- ):
621
- if self.question_class_name and self.generation_seed is not None and self.question_version:
638
+ if hasattr(self, 'question_class_name') and hasattr(self, 'generation_seed'):
639
+ if self.question_class_name and self.generation_seed is not None:
622
640
  extra_data['question_type'] = self.question_class_name
623
641
  extra_data['seed'] = self.generation_seed
624
- extra_data['version'] = self.question_version
642
+ if hasattr(self, 'question_version') and self.question_version:
643
+ extra_data['version'] = self.question_version
625
644
  # Include question-specific configuration parameters if available
626
645
  if hasattr(self, 'config_params') and self.config_params:
627
646
  extra_data['config'] = self.config_params
647
+ # Include context-derived extras if available
648
+ if hasattr(self, 'qr_context_extras') and self.qr_context_extras:
649
+ extra_data['context'] = self.qr_context_extras
628
650
 
629
651
  # Generate QR code PNG
630
652
  qr_path = QuestionQRCode.generate_qr_pdf(
@@ -668,7 +690,7 @@ class Section(Container):
668
690
  - Organizing complex question content
669
691
 
670
692
  Example:
671
- def _build_body(self, context):
693
+ def _build_body(cls, context):
672
694
  body = Section()
673
695
  answers = []
674
696
  body.add_element(Paragraph(["Calculate the determinant:"]))
QuizGenerator/generate.py CHANGED
@@ -32,7 +32,18 @@ def parse_args():
32
32
 
33
33
  parser.add_argument("--debug", action="store_true", help="Set logging level to debug")
34
34
 
35
- parser.add_argument("--quiz_yaml", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "example_files/exam_generation.yaml"))
35
+ parser.add_argument(
36
+ "--yaml",
37
+ dest="quiz_yaml",
38
+ default=None,
39
+ help="Path to quiz YAML configuration"
40
+ )
41
+ parser.add_argument(
42
+ "--quiz_yaml",
43
+ dest="quiz_yaml",
44
+ default=None,
45
+ help=argparse.SUPPRESS # Backwards-compatible alias for --yaml
46
+ )
36
47
  parser.add_argument("--seed", type=int, default=None,
37
48
  help="Random seed for quiz generation (default: None for random)")
38
49
 
@@ -45,7 +56,10 @@ def parse_args():
45
56
 
46
57
  # PDF Flags
47
58
  parser.add_argument("--num_pdfs", default=0, type=int, help="How many PDF quizzes to create")
48
- parser.add_argument("--latex", action="store_false", dest="typst", help="Use Typst instead of LaTeX for PDF generation")
59
+ parser.add_argument("--latex", action="store_false", dest="typst", help="Use LaTeX instead of Typst for PDF generation")
60
+ parser.set_defaults(typst=True)
61
+ parser.add_argument("--typst_measurement", action="store_true",
62
+ help="Use Typst measurement for layout optimization (experimental)")
49
63
 
50
64
  # Testing flags
51
65
  parser.add_argument("--test_all", type=int, default=0, metavar="N",
@@ -54,6 +68,8 @@ def parse_args():
54
68
  help="Only test specific question types by name (use with --test_all)")
55
69
  parser.add_argument("--strict", action="store_true",
56
70
  help="With --test_all, skip PDF/Canvas generation if any questions fail")
71
+ parser.add_argument("--skip_missing_extras", action="store_true",
72
+ help="With --test_all, skip questions that fail due to missing optional dependencies")
57
73
 
58
74
  subparsers = parser.add_subparsers(dest='command')
59
75
  test_parser = subparsers.add_parser("TEST")
@@ -65,6 +81,10 @@ def parse_args():
65
81
  log.error("Must provide course_id when pushing to canvas")
66
82
  exit(8)
67
83
 
84
+ if args.test_all <= 0 and not args.quiz_yaml:
85
+ log.error("Must provide --yaml unless using --test_all")
86
+ exit(8)
87
+
68
88
  return args
69
89
 
70
90
 
@@ -82,7 +102,8 @@ def test_all_questions(
82
102
  use_typst: bool = True,
83
103
  canvas_course=None,
84
104
  strict: bool = False,
85
- question_filter: list = None
105
+ question_filter: list = None,
106
+ skip_missing_extras: bool = False
86
107
  ):
87
108
  """
88
109
  Test all registered questions by generating N variations of each.
@@ -129,6 +150,7 @@ def test_all_questions(
129
150
  print("=" * 70)
130
151
 
131
152
  failed_questions = []
153
+ skipped_questions = []
132
154
  successful_questions = []
133
155
  # Collect question instances for PDF/Canvas generation
134
156
  test_question_instances = []
@@ -153,7 +175,7 @@ def test_all_questions(
153
175
  )
154
176
 
155
177
  # Generate the question (this calls refresh and builds the AST)
156
- instance = question.instantiate(rng_seed=seed)
178
+ instance = question.instantiate(rng_seed=seed, max_backoff_attempts=200)
157
179
  question_ast = question._build_question_ast(instance)
158
180
 
159
181
  # Try rendering to both formats to catch format-specific issues
@@ -174,6 +196,14 @@ def test_all_questions(
174
196
  # If we got here, the question works - save the instance
175
197
  test_question_instances.append(question)
176
198
 
199
+ except ImportError as e:
200
+ if skip_missing_extras:
201
+ skipped_questions.append(question_name)
202
+ log.warning(f"Skipping {question_name} due to missing optional dependency: {e}")
203
+ question_failures = []
204
+ break
205
+ tb = traceback.format_exc()
206
+ question_failures.append(f" Variation {variation+1}: Generation failed - {e}\n{tb}")
177
207
  except Exception as e:
178
208
  tb = traceback.format_exc()
179
209
  question_failures.append(f" Variation {variation+1}: Generation failed - {e}\n{tb}")
@@ -183,6 +213,8 @@ def test_all_questions(
183
213
  for failure in question_failures:
184
214
  print(failure)
185
215
  failed_questions.append((question_name, question_failures))
216
+ elif question_name in skipped_questions:
217
+ print(" SKIPPED (missing optional dependency)")
186
218
  else:
187
219
  print(f" OK ({num_variations}/{num_variations} variations)")
188
220
  successful_questions.append(question_name)
@@ -194,11 +226,17 @@ def test_all_questions(
194
226
  print(f"Total question types: {total_questions}")
195
227
  print(f"Successful: {len(successful_questions)}")
196
228
  print(f"Failed: {len(failed_questions)}")
229
+ if skipped_questions:
230
+ print(f"Skipped (missing extras): {len(set(skipped_questions))}")
197
231
 
198
232
  if failed_questions:
199
233
  print("\nFailed questions:")
200
234
  for name, failures in failed_questions:
201
235
  print(f" - {name}: {len(failures)} failures")
236
+ if skipped_questions:
237
+ print("\nSkipped questions (missing extras):")
238
+ for name in sorted(set(skipped_questions)):
239
+ print(f" - {name}")
202
240
 
203
241
  print("=" * 70)
204
242
 
@@ -367,14 +405,15 @@ def generate_quiz(
367
405
  delete_assignment_group=False,
368
406
  use_typst=False,
369
407
  use_typst_measurement=False,
370
- base_seed=None
408
+ base_seed=None,
409
+ env_path=None
371
410
  ):
372
411
 
373
412
  quizzes = Quiz.from_yaml(path_to_quiz_yaml)
374
413
 
375
414
  # Handle Canvas uploads with shared assignment group
376
415
  if num_canvas > 0:
377
- canvas_interface = CanvasInterface(prod=use_prod)
416
+ canvas_interface = CanvasInterface(prod=use_prod, env_path=env_path)
378
417
  canvas_course = canvas_interface.get_course(course_id=course_id)
379
418
 
380
419
  # Create assignment group once, with delete flag if specified
@@ -448,7 +487,7 @@ def main():
448
487
  # Set up Canvas course if course_id provided
449
488
  canvas_course = None
450
489
  if args.course_id:
451
- canvas_interface = CanvasInterface(prod=args.prod)
490
+ canvas_interface = CanvasInterface(prod=args.prod, env_path=args.env)
452
491
  canvas_course = canvas_interface.get_course(course_id=args.course_id)
453
492
 
454
493
  success = test_all_questions(
@@ -457,7 +496,8 @@ def main():
457
496
  use_typst=getattr(args, 'typst', True),
458
497
  canvas_course=canvas_course,
459
498
  strict=args.strict,
460
- question_filter=args.test_questions
499
+ question_filter=args.test_questions,
500
+ skip_missing_extras=args.skip_missing_extras
461
501
  )
462
502
  exit(0 if success else 1)
463
503
 
@@ -471,9 +511,10 @@ def main():
471
511
  use_prod=args.prod,
472
512
  course_id=args.course_id,
473
513
  delete_assignment_group=getattr(args, 'delete_assignment_group', False),
474
- use_typst=getattr(args, 'typst', False),
514
+ use_typst=getattr(args, 'typst', True),
475
515
  use_typst_measurement=getattr(args, 'typst_measurement', False),
476
- base_seed=getattr(args, 'seed', None)
516
+ base_seed=getattr(args, 'seed', None),
517
+ env_path=args.env
477
518
  )
478
519
 
479
520
 
@@ -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]
QuizGenerator/mixins.py CHANGED
@@ -419,8 +419,10 @@ class MathOperationQuestion(MultiPartQuestionMixin, abc.ABC):
419
419
  subparts.append((operand_a_latex, self.get_operator(), operand_b_latex))
420
420
  return subparts
421
421
 
422
- def _build_body(self, context):
422
+ @classmethod
423
+ def _build_body(cls, context):
423
424
  """Build question body and collect answers."""
425
+ self = context
424
426
  body = ca.Section()
425
427
  answers = []
426
428
 
@@ -453,8 +455,10 @@ class MathOperationQuestion(MultiPartQuestionMixin, abc.ABC):
453
455
  # Default implementation - subclasses should override for specific answer formats
454
456
  return []
455
457
 
456
- def _build_explanation(self, context):
458
+ @classmethod
459
+ def _build_explanation(cls, context):
457
460
  """Default explanation structure. Subclasses should override for specific explanations."""
461
+ self = context
458
462
  explanation = ca.Section()
459
463
 
460
464
  explanation.add_element(ca.Paragraph([self.get_explanation_intro()]))
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from typing import Tuple, List
5
+ from types import SimpleNamespace
5
6
  import random
6
7
 
7
8
  import logging
@@ -17,19 +18,23 @@ log = logging.getLogger(__name__)
17
18
  class FromText(Question):
18
19
 
19
20
  def __init__(self, *args, text, **kwargs):
21
+ kwargs["text"] = text
20
22
  super().__init__(*args, **kwargs)
21
23
  self.text = text
22
24
  self.possible_variations = 1
23
25
 
24
- def _build_context(self, *, rng_seed=None, **kwargs):
26
+ @classmethod
27
+ def _build_context(cls, *, rng_seed=None, **kwargs):
25
28
  context = super()._build_context(rng_seed=rng_seed, **kwargs)
26
- context["text"] = self.text
29
+ context["text"] = kwargs.get("text", "")
27
30
  return context
28
31
 
29
- def _build_body(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
32
+ @classmethod
33
+ def _build_body(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
30
34
  return ca.Section([ca.Text(context["text"])]), []
31
35
 
32
- def _build_explanation(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
36
+ @classmethod
37
+ def _build_explanation(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
33
38
  return ca.Section(), []
34
39
 
35
40
 
@@ -43,6 +48,7 @@ class FromGenerator(FromText, TableQuestionMixin):
43
48
  if generator is None:
44
49
  generator = text
45
50
 
51
+ kwargs["generator"] = generator
46
52
  super().__init__(*args, text="", **kwargs)
47
53
  self.possible_variations = kwargs.get("possible_variations", float('inf'))
48
54
 
@@ -72,15 +78,51 @@ class FromGenerator(FromText, TableQuestionMixin):
72
78
  # Attach the function dynamically
73
79
  attach_function_to_object(self, generator, "generator")
74
80
 
75
- def _build_context(self, *, rng_seed=None, **kwargs):
81
+ @staticmethod
82
+ def _compile_generator(function_code, function_name="generator"):
83
+ # Provide a deterministic RNG handle for generator snippets.
84
+ function_code = "rng = self.rng\n" + function_code
85
+
86
+ local_namespace = {
87
+ 'ca': ca,
88
+ 'Section': ca.Section,
89
+ 'Text': ca.Text,
90
+ 'Table': ca.Table,
91
+ 'Paragraph': ca.Paragraph
92
+ }
93
+
94
+ exec_globals = {**globals(), **local_namespace}
95
+ exec(
96
+ f"def {function_name}(self):\n" + "\n".join(f" {line}" for line in function_code.splitlines()),
97
+ exec_globals,
98
+ local_namespace
99
+ )
100
+ return local_namespace[function_name]
101
+
102
+ @classmethod
103
+ def _build_context(cls, *, rng_seed=None, **kwargs):
76
104
  context = super()._build_context(rng_seed=rng_seed, **kwargs)
105
+ for key, value in kwargs.items():
106
+ if key not in context:
107
+ context[key] = value
77
108
  # Preserve prior behavior for generators that use the global random module.
78
109
  random.seed(rng_seed)
110
+ generator_text = kwargs.get("generator")
111
+ if generator_text is not None:
112
+ context["generator_fn"] = cls._compile_generator(generator_text)
113
+ context["generator_scope"] = SimpleNamespace(
114
+ rng=context.rng,
115
+ **context.data
116
+ )
79
117
  return context
80
118
 
81
- def _build_body(self, context) -> Tuple[ca.Element, List[ca.Answer]]:
119
+ @classmethod
120
+ def _build_body(cls, context) -> Tuple[ca.Element, List[ca.Answer]]:
82
121
  try:
83
- generated_content = self.generator()
122
+ generator_fn = context.get("generator_fn")
123
+ if generator_fn is None:
124
+ raise TypeError("No generator provided for FromGenerator.")
125
+ generated_content = generator_fn(context.get("generator_scope"))
84
126
  if isinstance(generated_content, ca.Section):
85
127
  body = generated_content
86
128
  elif isinstance(generated_content, str):