QuizGenerator 0.9.0__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.
@@ -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(
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 = []
@@ -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]
@@ -198,7 +198,8 @@ class LossQuestion_Linear(LossQuestion):
198
198
  @classmethod
199
199
  def _generate_data(cls, context):
200
200
  """Generate regression data with continuous target values."""
201
- context.data = []
201
+ context.data = {}
202
+ context["data"] = []
202
203
 
203
204
  for _ in range(context.num_samples):
204
205
  sample = {}
@@ -227,7 +228,7 @@ class LossQuestion_Linear(LossQuestion):
227
228
  for _ in range(context.num_output_vars)
228
229
  ]
229
230
 
230
- context.data.append(sample)
231
+ context["data"].append(sample)
231
232
 
232
233
  @classmethod
233
234
  def _calculate_losses(cls, context):
@@ -235,7 +236,7 @@ class LossQuestion_Linear(LossQuestion):
235
236
  context.individual_losses = []
236
237
  total_loss = 0.0
237
238
 
238
- for sample in context.data:
239
+ for sample in context["data"]:
239
240
  if context.num_output_vars == 1:
240
241
  # Single output MSE: (y - p)^2
241
242
  loss = (sample['true_values'] - sample['predictions']) ** 2
@@ -282,7 +283,7 @@ class LossQuestion_Linear(LossQuestion):
282
283
  headers.append("loss")
283
284
 
284
285
  rows = []
285
- for i, sample in enumerate(context.data):
286
+ for i, sample in enumerate(context["data"]):
286
287
  row = {}
287
288
 
288
289
  # Input features as vector
@@ -315,7 +316,7 @@ class LossQuestion_Linear(LossQuestion):
315
316
  """Show step-by-step MSE calculations."""
316
317
  steps = ca.Section()
317
318
 
318
- for i, sample in enumerate(context.data):
319
+ for i, sample in enumerate(context["data"]):
319
320
  steps.add_element(ca.Paragraph([f"Sample {i+1}:"]))
320
321
 
321
322
  if context.num_output_vars == 1:
@@ -364,7 +365,7 @@ class LossQuestion_Linear(LossQuestion):
364
365
  headers.append("loss")
365
366
 
366
367
  rows = []
367
- for i, sample in enumerate(context.data):
368
+ for i, sample in enumerate(context["data"]):
368
369
  row = []
369
370
 
370
371
  # Input features
@@ -416,7 +417,8 @@ class LossQuestion_Logistic(LossQuestion):
416
417
  @classmethod
417
418
  def _generate_data(cls, context):
418
419
  """Generate binary classification data."""
419
- context.data = []
420
+ context.data = {}
421
+ context["data"] = []
420
422
 
421
423
  for _ in range(context.num_samples):
422
424
  sample = {}
@@ -433,7 +435,7 @@ class LossQuestion_Logistic(LossQuestion):
433
435
  # Generate predicted probabilities (between 0 and 1, rounded to 3 decimal places)
434
436
  sample['predictions'] = round(context.rng.uniform(0.1, 0.9), 3) # Avoid extreme values
435
437
 
436
- context.data.append(sample)
438
+ context["data"].append(sample)
437
439
 
438
440
  @classmethod
439
441
  def _calculate_losses(cls, context):
@@ -441,7 +443,7 @@ class LossQuestion_Logistic(LossQuestion):
441
443
  context.individual_losses = []
442
444
  total_loss = 0.0
443
445
 
444
- for sample in context.data:
446
+ for sample in context["data"]:
445
447
  y = sample['true_values']
446
448
  p = sample['predictions']
447
449
 
@@ -475,7 +477,7 @@ class LossQuestion_Logistic(LossQuestion):
475
477
  headers = ["x", "y", "p", "loss"]
476
478
 
477
479
  rows = []
478
- for i, sample in enumerate(context.data):
480
+ for i, sample in enumerate(context["data"]):
479
481
  row = {}
480
482
 
481
483
  # Input features as vector
@@ -500,7 +502,7 @@ class LossQuestion_Logistic(LossQuestion):
500
502
  """Show step-by-step log-loss calculations."""
501
503
  steps = ca.Section()
502
504
 
503
- for i, sample in enumerate(context.data):
505
+ for i, sample in enumerate(context["data"]):
504
506
  y = sample['true_values']
505
507
  p = sample['predictions']
506
508
  loss = context.individual_losses[i]
@@ -522,7 +524,7 @@ class LossQuestion_Logistic(LossQuestion):
522
524
  headers = ["x_0", "x_1", "y", "p", "loss"]
523
525
 
524
526
  rows = []
525
- for i, sample in enumerate(context.data):
527
+ for i, sample in enumerate(context["data"]):
526
528
  row = []
527
529
 
528
530
  # Input features
@@ -585,7 +587,8 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
585
587
  @classmethod
586
588
  def _generate_data(cls, context):
587
589
  """Generate multi-class classification data."""
588
- context.data = []
590
+ context.data = {}
591
+ context["data"] = []
589
592
 
590
593
  for _ in range(context.num_samples):
591
594
  sample = {}
@@ -606,7 +609,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
606
609
  prob_sum = sum(raw_probs)
607
610
  sample['predictions'] = [round(p / prob_sum, 3) for p in raw_probs]
608
611
 
609
- context.data.append(sample)
612
+ context["data"].append(sample)
610
613
 
611
614
  @classmethod
612
615
  def _calculate_losses(cls, context):
@@ -614,7 +617,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
614
617
  context.individual_losses = []
615
618
  total_loss = 0.0
616
619
 
617
- for sample in context.data:
620
+ for sample in context["data"]:
618
621
  y_vec = sample['true_values']
619
622
  p_vec = sample['predictions']
620
623
 
@@ -645,7 +648,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
645
648
  headers = ["x", "y", "p", "loss"]
646
649
 
647
650
  rows = []
648
- for i, sample in enumerate(context.data):
651
+ for i, sample in enumerate(context["data"]):
649
652
  row = {}
650
653
 
651
654
  # Input features as vector
@@ -672,7 +675,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
672
675
  """Show step-by-step cross-entropy calculations."""
673
676
  steps = ca.Section()
674
677
 
675
- for i, sample in enumerate(context.data):
678
+ for i, sample in enumerate(context["data"]):
676
679
  y_vec = sample['true_values']
677
680
  p_vec = sample['predictions']
678
681
  loss = context.individual_losses[i]
@@ -710,7 +713,7 @@ class LossQuestion_MulticlassLogistic(LossQuestion):
710
713
  headers = ["x_0", "x_1", "y", "p", "loss"]
711
714
 
712
715
  rows = []
713
- for i, sample in enumerate(context.data):
716
+ for i, sample in enumerate(context["data"]):
714
717
  row = []
715
718
 
716
719
  # Input features
@@ -1,7 +1,6 @@
1
1
  import abc
2
2
  import logging
3
3
  import math
4
- import keras
5
4
  import numpy as np
6
5
  from typing import List, Tuple
7
6
 
@@ -1,7 +1,6 @@
1
1
  import abc
2
2
  import logging
3
3
  import math
4
- import keras
5
4
  import numpy as np
6
5
  from typing import List, Tuple
7
6
 
@@ -1,7 +1,6 @@
1
1
  import abc
2
2
  import logging
3
3
  import math
4
- import keras
5
4
  import numpy as np
6
5
  from typing import List, Tuple
7
6
 
@@ -1,7 +1,6 @@
1
1
  import abc
2
2
  import logging
3
3
  import math
4
- import keras
5
4
  import numpy as np
6
5
  from typing import List, Tuple
7
6
 
@@ -1,8 +1,16 @@
1
+ from __future__ import annotations
2
+
1
3
  import abc
2
4
  import logging
3
5
  import math
4
6
  import random
5
- import keras
7
+ try:
8
+ import keras
9
+ except ImportError as exc:
10
+ keras = None
11
+ _KERAS_IMPORT_ERROR = exc
12
+ else:
13
+ _KERAS_IMPORT_ERROR = None
6
14
  import numpy as np
7
15
  from typing import List, Tuple
8
16
 
@@ -14,6 +22,14 @@ log = logging.getLogger(__name__)
14
22
 
15
23
 
16
24
  class WeightCounting(Question, abc.ABC):
25
+ @staticmethod
26
+ def _ensure_keras():
27
+ if keras is None:
28
+ raise ImportError(
29
+ "Keras is required for CST463 model questions. "
30
+ "Install with: pip install 'QuizGenerator[cst463]'"
31
+ ) from _KERAS_IMPORT_ERROR
32
+
17
33
  @staticmethod
18
34
  @abc.abstractmethod
19
35
  def get_model(rng: random.Random) -> keras.Model:
@@ -21,6 +37,7 @@ class WeightCounting(Question, abc.ABC):
21
37
 
22
38
  @staticmethod
23
39
  def model_to_python(model: keras.Model, fields=None, include_input=True):
40
+ WeightCounting._ensure_keras()
24
41
  if fields is None:
25
42
  fields = []
26
43
 
@@ -177,6 +194,7 @@ class WeightCounting_CNN(WeightCounting):
177
194
 
178
195
  @staticmethod
179
196
  def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
197
+ WeightCounting._ensure_keras()
180
198
  input_size = rng.choice(np.arange(28, 32))
181
199
  cnn_num_filters = rng.choice(2 ** np.arange(8))
182
200
  cnn_kernel_size = rng.choice(1 + np.arange(10))
@@ -211,6 +229,7 @@ class WeightCounting_CNN(WeightCounting):
211
229
  class WeightCounting_RNN(WeightCounting):
212
230
  @staticmethod
213
231
  def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
232
+ WeightCounting._ensure_keras()
214
233
  timesteps = int(rng.choice(np.arange(20, 41)))
215
234
  feature_size = int(rng.choice(np.arange(8, 65)))
216
235
 
@@ -9,17 +9,21 @@ The QR codes include encrypted data that allows regenerating question answers
9
9
  without storing separate files, enabling efficient grading of randomized exams.
10
10
  """
11
11
 
12
+ import base64
13
+ import hashlib
12
14
  import json
13
- import tempfile
14
15
  import logging
15
16
  import os
16
- import base64
17
+ import tempfile
18
+ import zlib
19
+ from datetime import datetime
17
20
  from io import BytesIO
18
21
  from pathlib import Path
19
22
  from typing import Optional, Dict, Any
20
23
 
21
24
  import segno
22
25
  from cryptography.fernet import Fernet
26
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
27
 
24
28
  log = logging.getLogger(__name__)
25
29
 
@@ -38,6 +42,22 @@ class QuestionQRCode:
38
42
 
39
43
  # Error correction level: M = 15% recovery (balanced for compact encoded data)
40
44
  ERROR_CORRECTION = 'M'
45
+ _generated_key: Optional[bytes] = None
46
+ V2_PREFIX = "v2."
47
+
48
+ @classmethod
49
+ def _persist_generated_key(cls, key: bytes) -> None:
50
+ try:
51
+ os.makedirs("out/keys", exist_ok=True)
52
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
53
+ path = os.path.join("out", "keys", f"quiz_encryption_key-{timestamp}.log")
54
+ with open(path, "w", encoding="utf-8") as handle:
55
+ handle.write("QUIZ_ENCRYPTION_KEY=")
56
+ handle.write(key.decode("ascii"))
57
+ handle.write("\n")
58
+ log.warning(f"Wrote generated QUIZ_ENCRYPTION_KEY to {path}")
59
+ except Exception as exc:
60
+ log.warning(f"Failed to persist generated QUIZ_ENCRYPTION_KEY: {exc}")
41
61
 
42
62
  @classmethod
43
63
  def get_encryption_key(cls) -> bytes:
@@ -61,67 +81,95 @@ class QuestionQRCode:
61
81
  "QUIZ_ENCRYPTION_KEY not set! Generating temporary key. "
62
82
  "Set this environment variable for production use!"
63
83
  )
64
- # Generate temporary key for development
65
- return Fernet.generate_key()
84
+ if cls._generated_key is None:
85
+ cls._generated_key = Fernet.generate_key()
86
+ os.environ["QUIZ_ENCRYPTION_KEY"] = cls._generated_key.decode("ascii")
87
+ cls._persist_generated_key(cls._generated_key)
88
+ return cls._generated_key
66
89
 
67
90
  # Key should be stored as base64 string in env
68
91
  return key_str.encode()
69
92
 
70
93
  @classmethod
71
- def encrypt_question_data(cls, question_type: str, seed: int, version: str,
94
+ def _derive_aead_key(cls, key: bytes) -> bytes:
95
+ return hashlib.sha256(key).digest()
96
+
97
+ @classmethod
98
+ def _encrypt_v2(cls, payload: Dict[str, Any], *, key: Optional[bytes] = None) -> str:
99
+ if key is None:
100
+ key = cls.get_encryption_key()
101
+ aead_key = cls._derive_aead_key(key)
102
+ aesgcm = AESGCM(aead_key)
103
+ nonce = os.urandom(12)
104
+ json_bytes = json.dumps(payload, separators=(',', ':'), ensure_ascii=False).encode("utf-8")
105
+ compressed = zlib.compress(json_bytes)
106
+ ciphertext = aesgcm.encrypt(nonce, compressed, None)
107
+ token = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
108
+ return f"{cls.V2_PREFIX}{token}"
109
+
110
+ @classmethod
111
+ def _decrypt_v2(cls, encrypted_data: str, *, key: Optional[bytes] = None) -> Dict[str, Any]:
112
+ if not encrypted_data.startswith(cls.V2_PREFIX):
113
+ raise ValueError("Not a v2 payload")
114
+ if key is None:
115
+ key = cls.get_encryption_key()
116
+ aead_key = cls._derive_aead_key(key)
117
+ token = encrypted_data[len(cls.V2_PREFIX):]
118
+ raw = base64.urlsafe_b64decode(token.encode("ascii"))
119
+ nonce, ciphertext = raw[:12], raw[12:]
120
+ aesgcm = AESGCM(aead_key)
121
+ compressed = aesgcm.decrypt(nonce, ciphertext, None)
122
+ json_bytes = zlib.decompress(compressed)
123
+ return json.loads(json_bytes.decode("utf-8"))
124
+
125
+ @classmethod
126
+ def encrypt_question_data(cls, question_type: str, seed: int, version: Optional[str] = None,
72
127
  config: Optional[Dict[str, Any]] = None,
128
+ context: Optional[Dict[str, Any]] = None,
129
+ points_value: Optional[float] = None,
73
130
  key: Optional[bytes] = None) -> str:
74
131
  """
75
- Encode question regeneration data with optional simple obfuscation.
132
+ Encode question regeneration data for QR embedding.
76
133
 
77
134
  Args:
78
135
  question_type: Class name of the question (e.g., "VectorDotProduct")
79
136
  seed: Random seed used to generate this specific question
80
- version: Question class version (e.g., "1.0")
137
+ version: Optional question version string
81
138
  config: Optional dictionary of configuration parameters
139
+ context: Optional dictionary of context extras
140
+ points_value: Optional points value (for redundancy)
82
141
  key: Encryption key (uses environment key if None)
83
142
 
84
143
  Returns:
85
- str: Base64-encoded (optionally XOR-obfuscated) data
144
+ str: Base64-encoded encrypted payload with v2 prefix
86
145
 
87
146
  Example:
88
147
  >>> encrypted = QuestionQRCode.encrypt_question_data("VectorDot", 12345, "1.0")
89
148
  >>> print(encrypted)
90
149
  'VmVjdG9yRG90OjEyMzQ1OjEuMA=='
91
150
  """
92
- # Create compact data string, including config if provided
151
+ payload: Dict[str, Any] = {
152
+ "t": question_type,
153
+ "s": seed,
154
+ }
155
+ if points_value is not None:
156
+ payload["p"] = points_value
93
157
  if config:
94
- # Serialize config as JSON and append to data string
95
- config_json = json.dumps(config, separators=(',', ':'))
96
- data_str = f"{question_type}:{seed}:{version}:{config_json}"
97
- else:
98
- data_str = f"{question_type}:{seed}:{version}"
99
- data_bytes = data_str.encode('utf-8')
100
-
101
- # Simple XOR obfuscation if key is provided (optional, for basic protection)
102
- if key is None:
103
- key = cls.get_encryption_key()
104
-
105
- if key:
106
- # Use first 16 bytes of key for simple XOR obfuscation
107
- key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
108
- # XOR each byte with repeating key pattern
109
- obfuscated = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(data_bytes))
110
- else:
111
- obfuscated = data_bytes
112
-
113
- # Base64 encode for compact representation
114
- encoded = base64.urlsafe_b64encode(obfuscated).decode('utf-8')
115
-
116
- log.debug(f"Encoded question data: {question_type} seed={seed} version={version} ({len(encoded)} chars)")
117
-
158
+ payload["c"] = config
159
+ if context:
160
+ payload["x"] = context
161
+ if version:
162
+ payload["v"] = version
163
+
164
+ encoded = cls._encrypt_v2(payload, key=key)
165
+ log.debug(f"Encoded question data v2: {question_type} seed={seed} ({len(encoded)} chars)")
118
166
  return encoded
119
167
 
120
168
  @classmethod
121
169
  def decrypt_question_data(cls, encrypted_data: str,
122
170
  key: Optional[bytes] = None) -> Dict[str, Any]:
123
171
  """
124
- Decode question regeneration data from QR code.
172
+ Decode question regeneration data from QR code (v2 preferred, v1 fallback).
125
173
 
126
174
  Args:
127
175
  encrypted_data: Base64-encoded (optionally XOR-obfuscated) string from QR code
@@ -142,10 +190,24 @@ class QuestionQRCode:
142
190
  key = cls.get_encryption_key()
143
191
 
144
192
  try:
145
- # Decode from base64
193
+ if encrypted_data.startswith(cls.V2_PREFIX):
194
+ payload = cls._decrypt_v2(encrypted_data, key=key)
195
+ result = {
196
+ "question_type": payload.get("t"),
197
+ "seed": int(payload.get("s")),
198
+ }
199
+ if "v" in payload:
200
+ result["version"] = payload.get("v")
201
+ if "c" in payload:
202
+ result["config"] = payload.get("c")
203
+ if "x" in payload:
204
+ result["context"] = payload.get("x")
205
+ if "p" in payload:
206
+ result["points"] = payload.get("p")
207
+ return result
208
+
209
+ # V1 fallback (XOR obfuscation)
146
210
  obfuscated = base64.urlsafe_b64decode(encrypted_data.encode())
147
-
148
- # Reverse XOR obfuscation if key is provided
149
211
  if key:
150
212
  key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
151
213
  data_bytes = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(obfuscated))
@@ -153,29 +215,26 @@ class QuestionQRCode:
153
215
  data_bytes = obfuscated
154
216
 
155
217
  data_str = data_bytes.decode('utf-8')
156
-
157
- # Parse data string - can be 3 or 4 parts (4th is optional config)
158
- parts = data_str.split(':', 3) # Split into max 4 parts
159
- if len(parts) < 3:
160
- raise ValueError(f"Invalid encoded data format: expected at least 3 parts, got {len(parts)}")
218
+ parts = data_str.split(':', 3)
219
+ if len(parts) < 2:
220
+ raise ValueError(f"Invalid encoded data format: expected at least 2 parts, got {len(parts)}")
161
221
 
162
222
  question_type = parts[0]
163
223
  seed_str = parts[1]
164
- version = parts[2]
224
+ version = parts[2] if len(parts) >= 3 else None
165
225
 
166
226
  result = {
167
227
  "question_type": question_type,
168
228
  "seed": int(seed_str),
169
- "version": version
170
229
  }
230
+ if version:
231
+ result["version"] = version
171
232
 
172
- # Parse config JSON if present
173
233
  if len(parts) == 4:
174
234
  try:
175
235
  result["config"] = json.loads(parts[3])
176
236
  except json.JSONDecodeError as e:
177
237
  log.warning(f"Failed to parse config JSON: {e}")
178
- # Continue without config rather than failing
179
238
 
180
239
  return result
181
240
 
@@ -215,24 +274,27 @@ class QuestionQRCode:
215
274
  """
216
275
  data = {
217
276
  "q": question_number,
218
- "pts": points_value
277
+ "p": points_value
219
278
  }
220
279
 
221
280
  # If question regeneration data provided, encrypt it
222
- if all(k in extra_data for k in ['question_type', 'seed', 'version']):
223
- # Include config in encrypted data if present
281
+ if all(k in extra_data for k in ['question_type', 'seed']):
224
282
  config = extra_data.get('config', {})
283
+ context = extra_data.get('context', {})
225
284
  encrypted = cls.encrypt_question_data(
226
285
  extra_data['question_type'],
227
286
  extra_data['seed'],
228
- extra_data['version'],
229
- config=config
287
+ extra_data.get('version'),
288
+ config=config,
289
+ context=context,
290
+ points_value=points_value
230
291
  )
231
292
  data['s'] = encrypted
232
293
 
233
- # Remove the unencrypted data
234
- extra_data = {k: v for k, v in extra_data.items()
235
- if k not in ['question_type', 'seed', 'version', 'config']}
294
+ extra_data = {
295
+ k: v for k, v in extra_data.items()
296
+ if k not in ['question_type', 'seed', 'version', 'config', 'context']
297
+ }
236
298
 
237
299
  # Add any remaining extra metadata
238
300
  data.update(extra_data)
QuizGenerator/question.py CHANGED
@@ -45,6 +45,7 @@ class RegenerationFlags:
45
45
  generation_seed: Optional[int]
46
46
  question_version: str
47
47
  config_params: Dict[str, Any]
48
+ context_extras: Dict[str, Any] = dataclasses.field(default_factory=dict)
48
49
 
49
50
 
50
51
  @dataclasses.dataclass(frozen=True)
@@ -94,7 +95,9 @@ class QuestionContext:
94
95
  return self.rng_seed
95
96
  if key == "rng":
96
97
  return self.rng
97
- return self.data.get(key, default)
98
+ if hasattr(self.data, "get"):
99
+ return self.data.get(key, default)
100
+ return default
98
101
 
99
102
  def __contains__(self, key: object) -> bool:
100
103
  if key in ("rng_seed", "rng"):
@@ -261,7 +264,12 @@ class QuestionRegistry:
261
264
  # Load modules from the current directory
262
265
  for _, module_name, _ in pkgutil.iter_modules([str(path)]):
263
266
  # Import the module
264
- module = importlib.import_module(f"{package_prefix}.{module_name}")
267
+ try:
268
+ importlib.import_module(f"{package_prefix}.{module_name}")
269
+ except ImportError as e:
270
+ log.warning(
271
+ f"Skipping module '{package_prefix}.{module_name}' due to import error: {e}"
272
+ )
265
273
 
266
274
  # Recursively load modules from subdirectories
267
275
  for subdir in path.iterdir():
@@ -688,6 +696,20 @@ class Question(abc.ABC):
688
696
  if isinstance(ctx, dict) and ctx.get("_config_params"):
689
697
  config_params.update(ctx.get("_config_params"))
690
698
 
699
+ context_extras: Dict[str, Any] = {}
700
+ if isinstance(ctx, QuestionContext):
701
+ include_list = ctx.get("qr_include_list", None)
702
+ if isinstance(include_list, (list, tuple)):
703
+ for key in include_list:
704
+ if key in ctx:
705
+ context_extras[key] = ctx[key]
706
+ elif isinstance(ctx, dict):
707
+ include_list = ctx.get("qr_include_list", None)
708
+ if isinstance(include_list, (list, tuple)):
709
+ for key in include_list:
710
+ if key in ctx:
711
+ context_extras[key] = ctx[key]
712
+
691
713
  instance = QuestionInstance(
692
714
  body=components.body,
693
715
  explanation=components.explanation,
@@ -701,7 +723,8 @@ class Question(abc.ABC):
701
723
  question_class_name=self._get_registered_name(),
702
724
  generation_seed=actual_seed,
703
725
  question_version=self.VERSION,
704
- config_params=config_params
726
+ config_params=config_params,
727
+ context_extras=context_extras
705
728
  )
706
729
  )
707
730
  return instance
@@ -932,6 +955,7 @@ class Question(abc.ABC):
932
955
  question_ast.generation_seed = instance.flags.generation_seed
933
956
  question_ast.question_version = instance.flags.question_version
934
957
  question_ast.config_params = dict(instance.flags.config_params)
958
+ question_ast.qr_context_extras = dict(instance.flags.context_extras)
935
959
 
936
960
  return question_ast
937
961
 
@@ -1014,11 +1038,11 @@ class QuestionGroup():
1014
1038
 
1015
1039
  def instantiate(self, *args, **kwargs):
1016
1040
 
1017
- # todo: Make work with rng_seed (or at least verify)
1018
- random.seed(kwargs.get("rng_seed", None))
1041
+ # Use a local RNG to avoid global side effects.
1042
+ rng = random.Random(kwargs.get("rng_seed", None))
1019
1043
 
1020
1044
  if not self.pick_once or self._current_question is None:
1021
- self._current_question = random.choice(self.questions)
1045
+ self._current_question = rng.choice(self.questions)
1022
1046
 
1023
1047
  def __getattr__(self, name):
1024
1048
  if self._current_question is None or name == "generate":
@@ -194,7 +194,9 @@ def regenerate_question_answer(
194
194
  }
195
195
  """
196
196
  question_num = qr_data.get('q')
197
- points = qr_data.get('pts')
197
+ points = qr_data.get('p')
198
+ if points is None:
199
+ points = qr_data.get('pts')
198
200
 
199
201
  if question_num is None or points is None:
200
202
  log.error("QR code missing required fields 'q' or 'pts'")
@@ -219,8 +221,9 @@ def regenerate_question_answer(
219
221
 
220
222
  question_type = regen_data['question_type']
221
223
  seed = regen_data['seed']
222
- version = regen_data['version']
224
+ version = regen_data.get('version')
223
225
  config = regen_data.get('config', {})
226
+ context_extras = regen_data.get('context', {})
224
227
 
225
228
  result['question_type'] = question_type
226
229
  result['seed'] = seed
@@ -228,7 +231,10 @@ def regenerate_question_answer(
228
231
  if config:
229
232
  result['config'] = config
230
233
 
231
- log.info(f"Question {question_num}: {question_type} (seed={seed}, version={version})")
234
+ if version:
235
+ log.info(f"Question {question_num}: {question_type} (seed={seed}, version={version})")
236
+ else:
237
+ log.info(f"Question {question_num}: {question_type} (seed={seed})")
232
238
  if config:
233
239
  log.debug(f" Config params: {config}")
234
240
 
@@ -241,7 +247,7 @@ def regenerate_question_answer(
241
247
  )
242
248
 
243
249
  # Generate question with the specific seed
244
- instance = question.instantiate(rng_seed=seed)
250
+ instance = question.instantiate(rng_seed=seed, **context_extras)
245
251
  question_ast = question._build_question_ast(instance)
246
252
 
247
253
  # Extract answers
@@ -477,14 +483,22 @@ def display_answer_summary(question_data: Dict[str, Any]) -> None:
477
483
  if 'question_type' in question_data:
478
484
  print(f"Type: {question_data['question_type']}")
479
485
  print(f"Seed: {question_data['seed']}")
480
- print(f"Version: {question_data['version']}")
486
+ if question_data.get('version') is not None:
487
+ print(f"Version: {question_data['version']}")
481
488
 
482
489
  if 'answer_objects' in question_data:
483
490
  print("\nANSWERS:")
484
- for key, answer_obj in question_data['answer_objects'].items():
485
- print(f" {key}: {answer_obj.value}")
486
- if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
487
- print(f" (tolerance: ±{answer_obj.tolerance})")
491
+ answer_objects = question_data['answer_objects']
492
+ if isinstance(answer_objects, dict):
493
+ for key, answer_obj in answer_objects.items():
494
+ print(f" {key}: {answer_obj.value}")
495
+ if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
496
+ print(f" (tolerance: ±{answer_obj.tolerance})")
497
+ else:
498
+ for i, answer_obj in enumerate(answer_objects, start=1):
499
+ print(f" {i}: {answer_obj.value}")
500
+ if hasattr(answer_obj, 'tolerance') and answer_obj.tolerance:
501
+ print(f" (tolerance: ±{answer_obj.tolerance})")
488
502
  elif 'answers' in question_data:
489
503
  print("\nANSWERS (raw Canvas format):")
490
504
  print(f" Type: {question_data['answers']['kind']}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QuizGenerator
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: Generate randomized quiz questions for Canvas LMS and PDF exams
5
5
  Project-URL: Homepage, https://github.com/OtterDen-Lab/QuizGenerator
6
6
  Project-URL: Documentation, https://github.com/OtterDen-Lab/QuizGenerator/tree/main/documentation
@@ -19,9 +19,7 @@ Classifier: Topic :: Education :: Testing
19
19
  Requires-Python: >=3.12
20
20
  Requires-Dist: canvasapi==3.2.0
21
21
  Requires-Dist: cryptography>=41.0.0
22
- Requires-Dist: graphviz>=0.21
23
22
  Requires-Dist: jinja2==3.1.3
24
- Requires-Dist: keras>=3.12.0
25
23
  Requires-Dist: markdown>=3.9
26
24
  Requires-Dist: matplotlib
27
25
  Requires-Dist: pylatex>=1.4.2
@@ -32,7 +30,10 @@ Requires-Dist: pyyaml==6.0.1
32
30
  Requires-Dist: requests==2.32.2
33
31
  Requires-Dist: segno>=1.6.0
34
32
  Requires-Dist: sympy>=1.14.0
35
- Requires-Dist: tensorflow>=2.20.0
33
+ Provides-Extra: cst463
34
+ Requires-Dist: graphviz>=0.21; extra == 'cst463'
35
+ Requires-Dist: keras>=3.12.0; extra == 'cst463'
36
+ Requires-Dist: tensorflow>=2.20.0; extra == 'cst463'
36
37
  Provides-Extra: grading
37
38
  Requires-Dist: pillow>=10.0.0; extra == 'grading'
38
39
  Requires-Dist: pyzbar>=0.1.9; extra == 'grading'
@@ -60,18 +61,24 @@ pip install QuizGenerator
60
61
  ### System Requirements
61
62
 
62
63
  - Python 3.12+
63
- - LaTeX distribution with `latexmk` (for PDF generation)
64
- - Optional: [Typst](https://typst.app/) (alternative to LaTeX)
64
+ - [Typst](https://typst.app/) (default PDF renderer)
65
+ - Optional: LaTeX distribution with `latexmk` (if using `--latex`)
66
+ - Recommended: [Pandoc](https://pandoc.org/) (for markdown conversion)
65
67
 
66
68
  ### Optional Dependencies
67
69
 
68
70
  ```bash
69
71
  # For QR code grading support
70
72
  pip install QuizGenerator[grading]
73
+
74
+ # For CST463 machine learning questions
75
+ pip install QuizGenerator[cst463]
71
76
  ```
72
77
 
73
78
  ## Quick Start
74
79
 
80
+ Need a 2‑minute setup? See `documentation/quickstart.md`.
81
+
75
82
  ### 1. Create a quiz configuration (YAML)
76
83
 
77
84
  ```yaml
@@ -94,7 +101,7 @@ questions:
94
101
  ### 2. Generate PDFs
95
102
 
96
103
  ```bash
97
- python -m generate_quiz --quiz_yaml my_quiz.yaml --num_pdfs 3
104
+ quizgen --yaml my_quiz.yaml --num_pdfs 3
98
105
  ```
99
106
 
100
107
  PDFs will be created in the `out/` directory.
@@ -106,8 +113,8 @@ PDFs will be created in the `out/` directory.
106
113
  # CANVAS_API_URL=https://canvas.instructure.com
107
114
  # CANVAS_API_KEY=your_api_key_here
108
115
 
109
- python -m generate_quiz \
110
- --quiz_yaml my_quiz.yaml \
116
+ quizgen \
117
+ --yaml my_quiz.yaml \
111
118
  --num_canvas 5 \
112
119
  --course_id 12345
113
120
  ```
@@ -194,10 +201,9 @@ Notes:
194
201
 
195
202
  ## Documentation
196
203
 
197
- - [Getting Started Guide](documentation/getting_started.md) (coming soon)
204
+ - [Getting Started Guide](documentation/getting_started.md)
198
205
  - [Custom Questions Guide](documentation/custom_questions.md)
199
- - [YAML Configuration Reference](documentation/yaml_config_guide.md) (coming soon)
200
- - [PyPI Release Plan](documentation/pypi_release_plan.md)
206
+ - [YAML Configuration Reference](documentation/yaml_config_guide.md)
201
207
 
202
208
  ## Canvas Setup
203
209
 
@@ -216,25 +222,28 @@ CANVAS_API_KEY_prod=your_prod_api_key
216
222
  2. Use `--prod` flag for production Canvas instance:
217
223
 
218
224
  ```bash
219
- python -m generate_quiz --prod --num_canvas 5 --course_id 12345
225
+ quizgen --prod --num_canvas 5 --course_id 12345
220
226
  ```
221
227
 
222
228
  ## Advanced Features
223
229
 
224
230
  ### Typst Support
225
231
 
226
- Use Typst instead of LaTeX for faster compilation:
232
+ Typst is the default for faster compilation. Use `--latex` to force LaTeX:
227
233
 
228
234
  ```bash
229
- python -m generate_quiz --typst --num_pdfs 3
235
+ quizgen --latex --num_pdfs 3
230
236
  ```
231
237
 
238
+ Experimental: `--typst_measurement` uses Typst to measure question height for tighter layout.
239
+ It can change pagination and ordering, so use with care on finalized exams.
240
+
232
241
  ### Deterministic Generation
233
242
 
234
243
  Use seeds for reproducible quizzes:
235
244
 
236
245
  ```bash
237
- python -m generate_quiz --seed 42 --num_pdfs 3
246
+ quizgen --seed 42 --num_pdfs 3
238
247
  ```
239
248
 
240
249
  ### QR Code Regeneration
@@ -258,7 +267,7 @@ QuizGenerator/
258
267
  │ └── canvas/ # Canvas LMS integration
259
268
  ├── example_files/ # Example quiz configurations
260
269
  ├── documentation/ # User guides
261
- └── generate_quiz.py # CLI entry point
270
+ └── quizgen # CLI entry point
262
271
  ```
263
272
 
264
273
  ## Contributing
@@ -1,18 +1,20 @@
1
+ QuizGenerator/README.md,sha256=4n16gKyhIAKRBX4VKlpfcK0pyUYJ6Ht08MUsnwgxrZo,145
1
2
  QuizGenerator/__init__.py,sha256=8EV-k90A3PNC8Cm2-ZquwNyVyvnwW1gs6u-nGictyhs,840
2
3
  QuizGenerator/__main__.py,sha256=Dd9w4R0Unm3RiXztvR4Y_g9-lkWp6FHg-4VN50JbKxU,151
3
4
  QuizGenerator/constants.py,sha256=AO-UWwsWPLb1k2JW6KP8rl9fxTcdT0rW-6XC6zfnDOs,4386
4
- QuizGenerator/contentast.py,sha256=LEjr-J79ooge0sAlZMuJcyz5Xfj2wRHlAJ_7jAULhBY,87614
5
- QuizGenerator/generate.py,sha256=qXLJ3WfOo_poIWoAZvEK7epNlVNSWxpOomMVt2MDExA,15816
5
+ QuizGenerator/contentast.py,sha256=NY6Y9gWbdinXaVGdgwN61UBF2nz_L2urqr-IPfR6tV0,88475
6
+ QuizGenerator/generate.py,sha256=qKYT-7HLUS1WSViDDSuJOIb5RwsTT3BuoUUZkoqSSus,17363
7
+ QuizGenerator/logging.yaml,sha256=VJCdh26D8e_PNUs4McvvP1ojz9EVjQNifJzfhEk1Mbo,1114
6
8
  QuizGenerator/misc.py,sha256=MXrguUhhdrWSV4Hqdl4G21ktowODu1AcKy6-5mvy3aI,454
7
9
  QuizGenerator/mixins.py,sha256=zXj2U94qNbIEusbwTnzRM1Z_zSybpvozWhveq-t5q2Q,15771
8
10
  QuizGenerator/performance.py,sha256=CM3zLarJXN5Hfrl4-6JRBqD03j4BU1B2QW699HAr1Ds,7002
9
- QuizGenerator/qrcode_generator.py,sha256=S3mzZDk2UiHiw6ipSCpWPMhbKvSRR1P5ordZJUTo6ug,10776
10
- QuizGenerator/question.py,sha256=PKpQ6ZsHkyNw3yJBXlU3akS9Dqhmprq0dLu7wjRzg9A,37240
11
+ QuizGenerator/qrcode_generator.py,sha256=VMbAXFxJ_3KryqrgynCUPR5myeiu5CoroBfwwBsinCc,13386
12
+ QuizGenerator/question.py,sha256=BSopEaosCkJZ76etMv3Ue1zjzJpho-WnqS_8cQlZeac,38199
11
13
  QuizGenerator/quiz.py,sha256=CEWy7FB7BZiK33s_wYs6MqGKDetc6htUaqvP3--2HzI,21621
12
- QuizGenerator/regenerate.py,sha256=ZAs1mtERmO8JXza2tBqJpd-uJs9V7gS1jJ9A9gSb8jo,19764
14
+ QuizGenerator/regenerate.py,sha256=cDYZ9LwOSkX9P7W72O0c6RI8ORzS-DFnYhAdC5-7UrI,20375
13
15
  QuizGenerator/typst_utils.py,sha256=JGQn_u5bEHd8HAtjAHuZoVJwLkx-Rd4ZCBWffwFZa3o,3136
14
16
  QuizGenerator/canvas/__init__.py,sha256=TwFP_zgxPIlWtkvIqQ6mcvBNTL9swIH_rJl7DGKcvkQ,286
15
- QuizGenerator/canvas/canvas_interface.py,sha256=StMcdXgLvTA1EayQ44m_le2GXGQpDQnduYXVeUYsqW0,24618
17
+ QuizGenerator/canvas/canvas_interface.py,sha256=5PS7sZeIwRuOGK9gXmfgP4eUYtgCqZ1JKD-S4mNvsbk,24798
16
18
  QuizGenerator/canvas/classes.py,sha256=v_tQ8t_JJplU9sv2p4YctX45Fwed1nQ2HC1oC9BnDNw,7594
17
19
  QuizGenerator/premade_questions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
20
  QuizGenerator/premade_questions/basic.py,sha256=u6Viv__5HYWjLOknYt_jsBJTej5-cPd9FM1xsuwUQcQ,4613
@@ -27,24 +29,24 @@ QuizGenerator/premade_questions/cst463/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JC
27
29
  QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py,sha256=sH2CUV6zK9FT3jWTn453ys6_JTrUKRtZnU8hK6RmImU,240
28
30
  QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py,sha256=y_R26wUt7AIZVUoe3e_qpzkPdwMyo3mZjxLI504-YQw,13840
29
31
  QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py,sha256=OssybIkHx4l8ryHGT9rqHUecta9qpItK4QYVHuuQLeo,11525
30
- QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py,sha256=slNXfEeLRRM8IDhn3TSQdUWjAimDIfbgUSEmb48zCuU,24156
32
+ QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py,sha256=PMuEAUJmN12t4ffHF0FoSbQNCLDfW5BKozWNHvoC6GU,24276
31
33
  QuizGenerator/premade_questions/cst463/gradient_descent/misc.py,sha256=0R-nFeD3zsqJyde5CXWrF6Npjmpx6_HbzfCbThLi3os,2657
32
34
  QuizGenerator/premade_questions/cst463/math_and_data/__init__.py,sha256=EbIaUrx7_aK9j3Gd8Mk08h9GocTq_0OoNu2trfNwaU8,202
33
35
  QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py,sha256=4DLdo_8XDS_xtPA8R-wH4K0cKnMn4r5727Vszz8keTc,15565
34
36
  QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py,sha256=VXQCLQEeNKxRDPn_fGW5nAPX-0betrZ8mURh0ElbNz0,12668
35
37
  QuizGenerator/premade_questions/cst463/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
- QuizGenerator/premade_questions/cst463/models/attention.py,sha256=iECxOoR0LEJAH_d7ZE3MoLOkdYVbGOKo4Dwf8Pww0tM,5443
37
- QuizGenerator/premade_questions/cst463/models/cnns.py,sha256=_HSjgClNBBid5t52CDQfPUMdKOHFOXjy7VQ3sW-zW-Y,6030
38
+ QuizGenerator/premade_questions/cst463/models/attention.py,sha256=hb0sLVddxSKr4-bsIpAUyxITfcNaAGT-FppwwXXu52I,5430
39
+ QuizGenerator/premade_questions/cst463/models/cnns.py,sha256=p3hvQEsTsI2urPrZ-MZ63eIq0Dm6aarrLATVIiECc2A,6017
38
40
  QuizGenerator/premade_questions/cst463/models/matrices.py,sha256=21eNXUcIjFNZTGH18oH-x3MbCa4buNGQGer3a5yL85c,1938
39
- QuizGenerator/premade_questions/cst463/models/rnns.py,sha256=5fKQuWnpSAoznZVJuCY4nICQ5KzB04Cz17hpccYiiVc,6673
40
- QuizGenerator/premade_questions/cst463/models/text.py,sha256=BnW6qIB8pnQiFRyXxtX9cdsIfmjw99p6TI0WqI0AQzk,6605
41
- QuizGenerator/premade_questions/cst463/models/weight_counting.py,sha256=sFnEvSs7ZwR4RZPltiMEElKJgoxHTaY427_g8Abi2uk,6912
41
+ QuizGenerator/premade_questions/cst463/models/rnns.py,sha256=9pPzkrNviKD479NVgVvkoMhXtaLX0m17IIGpYKXXAwk,6660
42
+ QuizGenerator/premade_questions/cst463/models/text.py,sha256=j6ClSF4AztgFWyvuhjBCW5mKvQL1AdizfhpF3iCjtt4,6592
43
+ QuizGenerator/premade_questions/cst463/models/weight_counting.py,sha256=NriO0cukIw3HKYkm62p4PPm1qdH9wmDepEfoBzrmxR4,7402
42
44
  QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py,sha256=pmyCezO-20AFEQC6MR7KnAsaU9TcgZYsGQOMVkRZ-U8,149
43
45
  QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py,sha256=j2f5LFmme-2rSgJzcb8nZJ1_hnZaL-S4lXSnIbpoH_E,43010
44
46
  QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py,sha256=G1gEHtG4KakYgi8ZXSYYhX6bQRtnm2tZVGx36d63Nmo,173
45
47
  QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py,sha256=t0ghv6o8AsZaIwVHFu07Ozebwuv6Rf18D3g_-Jz4O74,31309
46
- quizgenerator-0.9.0.dist-info/METADATA,sha256=84oI93hpRcDQO03t5qMjloNUWcCQo9MgOZmkMe9YrbY,8177
47
- quizgenerator-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
48
- quizgenerator-0.9.0.dist-info/entry_points.txt,sha256=aOIdRdw26xY8HkxOoKHBnUPe2mwGv5Ti3U1zojb6zxQ,98
49
- quizgenerator-0.9.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
50
- quizgenerator-0.9.0.dist-info/RECORD,,
48
+ quizgenerator-0.10.0.dist-info/METADATA,sha256=zxcZ0akBMSH9jjA-sdpO55tZUlN4pfeSpIqFs9m864M,8483
49
+ quizgenerator-0.10.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
50
+ quizgenerator-0.10.0.dist-info/entry_points.txt,sha256=aOIdRdw26xY8HkxOoKHBnUPe2mwGv5Ti3U1zojb6zxQ,98
51
+ quizgenerator-0.10.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
52
+ quizgenerator-0.10.0.dist-info/RECORD,,