QuizGenerator 0.9.0__py3-none-any.whl → 0.10.1__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 (37) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +2 -1
  3. QuizGenerator/canvas/canvas_interface.py +9 -6
  4. QuizGenerator/canvas/classes.py +0 -1
  5. QuizGenerator/contentast.py +32 -10
  6. QuizGenerator/generate.py +57 -11
  7. QuizGenerator/logging.yaml +55 -0
  8. QuizGenerator/misc.py +0 -8
  9. QuizGenerator/premade_questions/cst334/memory_questions.py +2 -3
  10. QuizGenerator/premade_questions/cst334/process.py +0 -1
  11. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +10 -1
  12. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +0 -1
  13. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +2 -4
  14. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +22 -20
  15. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +1 -1
  16. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +11 -1
  17. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +0 -1
  18. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +0 -1
  19. QuizGenerator/premade_questions/cst463/models/attention.py +1 -5
  20. QuizGenerator/premade_questions/cst463/models/cnns.py +1 -5
  21. QuizGenerator/premade_questions/cst463/models/rnns.py +1 -5
  22. QuizGenerator/premade_questions/cst463/models/text.py +1 -5
  23. QuizGenerator/premade_questions/cst463/models/weight_counting.py +20 -3
  24. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +7 -0
  25. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1 -9
  26. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +7 -0
  27. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +0 -4
  28. QuizGenerator/qrcode_generator.py +116 -55
  29. QuizGenerator/question.py +30 -16
  30. QuizGenerator/quiz.py +1 -6
  31. QuizGenerator/regenerate.py +23 -9
  32. {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/METADATA +26 -17
  33. quizgenerator-0.10.1.dist-info/RECORD +52 -0
  34. quizgenerator-0.9.0.dist-info/RECORD +0 -50
  35. {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/WHEEL +0 -0
  36. {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/entry_points.txt +0 -0
  37. {quizgenerator-0.9.0.dist-info → quizgenerator-0.10.1.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,6 @@ import abc
3
3
  import logging
4
4
  import math
5
5
  import random
6
- from typing import List
7
6
 
8
7
  from QuizGenerator.question import Question, QuestionRegistry
9
8
  import QuizGenerator.contentast as ca
@@ -1,13 +1,9 @@
1
- import abc
2
1
  import logging
3
- import math
4
- import keras
5
2
  import numpy as np
6
3
  from typing import List, Tuple
7
4
 
8
- from QuizGenerator.question import Question, QuestionRegistry
5
+ from QuizGenerator.question import QuestionRegistry
9
6
  import QuizGenerator.contentast as ca
10
- from QuizGenerator.constants import MathRanges
11
7
  from QuizGenerator.mixins import TableQuestionMixin
12
8
 
13
9
  from .matrices import MatrixQuestion
@@ -1,13 +1,9 @@
1
- import abc
2
1
  import logging
3
- import math
4
- import keras
5
2
  import numpy as np
6
3
  from typing import List, Tuple
7
4
 
8
- from QuizGenerator.question import Question, QuestionRegistry
5
+ from QuizGenerator.question import QuestionRegistry
9
6
  import QuizGenerator.contentast as ca
10
- from QuizGenerator.constants import MathRanges
11
7
  from .matrices import MatrixQuestion
12
8
 
13
9
  log = logging.getLogger(__name__)
@@ -1,14 +1,10 @@
1
- import abc
2
1
  import logging
3
- import math
4
- import keras
5
2
  import numpy as np
6
3
  from typing import List, Tuple
7
4
 
8
5
  from .matrices import MatrixQuestion
9
- from QuizGenerator.question import Question, QuestionRegistry
6
+ from QuizGenerator.question import QuestionRegistry
10
7
  import QuizGenerator.contentast as ca
11
- from QuizGenerator.constants import MathRanges
12
8
  from QuizGenerator.mixins import TableQuestionMixin
13
9
 
14
10
  log = logging.getLogger(__name__)
@@ -1,14 +1,10 @@
1
- import abc
2
1
  import logging
3
- import math
4
- import keras
5
2
  import numpy as np
6
3
  from typing import List, Tuple
7
4
 
8
5
  from QuizGenerator.premade_questions.cst463.models.matrices import MatrixQuestion
9
- from QuizGenerator.question import Question, QuestionRegistry
6
+ from QuizGenerator.question import QuestionRegistry
10
7
  import QuizGenerator.contentast as ca
11
- from QuizGenerator.constants import MathRanges
12
8
  from QuizGenerator.mixins import TableQuestionMixin
13
9
 
14
10
  log = logging.getLogger(__name__)
@@ -1,19 +1,33 @@
1
+ from __future__ import annotations
2
+
1
3
  import abc
2
4
  import logging
3
- import math
4
5
  import random
5
- import keras
6
+ try:
7
+ import keras
8
+ except ImportError as exc:
9
+ keras = None
10
+ _KERAS_IMPORT_ERROR = exc
11
+ else:
12
+ _KERAS_IMPORT_ERROR = None
6
13
  import numpy as np
7
14
  from typing import List, Tuple
8
15
 
9
16
  from QuizGenerator.question import Question, QuestionRegistry
10
17
  import QuizGenerator.contentast as ca
11
- from QuizGenerator.constants import MathRanges
12
18
 
13
19
  log = logging.getLogger(__name__)
14
20
 
15
21
 
16
22
  class WeightCounting(Question, abc.ABC):
23
+ @staticmethod
24
+ def _ensure_keras():
25
+ if keras is None:
26
+ raise ImportError(
27
+ "Keras is required for CST463 model questions. "
28
+ "Install with: pip install 'QuizGenerator[cst463]'"
29
+ ) from _KERAS_IMPORT_ERROR
30
+
17
31
  @staticmethod
18
32
  @abc.abstractmethod
19
33
  def get_model(rng: random.Random) -> keras.Model:
@@ -21,6 +35,7 @@ class WeightCounting(Question, abc.ABC):
21
35
 
22
36
  @staticmethod
23
37
  def model_to_python(model: keras.Model, fields=None, include_input=True):
38
+ WeightCounting._ensure_keras()
24
39
  if fields is None:
25
40
  fields = []
26
41
 
@@ -177,6 +192,7 @@ class WeightCounting_CNN(WeightCounting):
177
192
 
178
193
  @staticmethod
179
194
  def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
195
+ WeightCounting._ensure_keras()
180
196
  input_size = rng.choice(np.arange(28, 32))
181
197
  cnn_num_filters = rng.choice(2 ** np.arange(8))
182
198
  cnn_kernel_size = rng.choice(1 + np.arange(10))
@@ -211,6 +227,7 @@ class WeightCounting_CNN(WeightCounting):
211
227
  class WeightCounting_RNN(WeightCounting):
212
228
  @staticmethod
213
229
  def get_model(rng: random.Random) -> tuple[keras.Model, list[str]]:
230
+ WeightCounting._ensure_keras()
214
231
  timesteps = int(rng.choice(np.arange(20, 41)))
215
232
  feature_size = int(rng.choice(np.arange(8, 65)))
216
233
 
@@ -4,3 +4,10 @@ from .neural_network_questions import (
4
4
  EnsembleAveragingQuestion,
5
5
  EndToEndTrainingQuestion
6
6
  )
7
+
8
+ __all__ = [
9
+ "ForwardPassQuestion",
10
+ "BackpropGradientQuestion",
11
+ "EnsembleAveragingQuestion",
12
+ "EndToEndTrainingQuestion",
13
+ ]
@@ -3,18 +3,13 @@ from __future__ import annotations
3
3
  import abc
4
4
  import io
5
5
  import logging
6
- import math
7
6
  import numpy as np
8
- import uuid
9
- import os
10
7
  from typing import List, Tuple
11
8
 
12
9
  import matplotlib.pyplot as plt
13
- import matplotlib.patches as mpatches
14
10
 
15
11
  import QuizGenerator.contentast as ca
16
12
  from QuizGenerator.question import Question, QuestionRegistry
17
- from QuizGenerator.mixins import TableQuestionMixin, BodyTemplatesMixin
18
13
  from ..models.matrices import MatrixQuestion
19
14
 
20
15
  log = logging.getLogger(__name__)
@@ -843,8 +838,7 @@ class BackpropGradientQuestion(SimpleNeuralNetworkBase):
843
838
  # Compute intermediate values
844
839
  dz2_da1 = self.W2[0, 0]
845
840
  da1_dz1 = self._activation_derivative(self.z1[0])
846
- dL_dz1 = self.dL_dz2 * dz2_da1 * da1_dz1
847
-
841
+
848
842
  grad = self._compute_gradient_W1(0, j)
849
843
 
850
844
  if self.activation_function == self.ACTIVATION_SIGMOID:
@@ -1131,7 +1125,6 @@ class EndToEndTrainingQuestion(SimpleNeuralNetworkBase):
1131
1125
  ]))
1132
1126
 
1133
1127
  # Hidden layer
1134
- z1_0 = self.W1[0, 0] * self.X[0] + self.W1[0, 1] * self.X[1] + self.b1[0]
1135
1128
  explanation.add_element(ca.Equation(
1136
1129
  f"z_1 = w_{{11}} x_1 + w_{{12}} x_2 + b_1 = {self.W1[0,0]:.{self.param_digits}f} \\cdot {self.X[0]:.1f} + {self.W1[0,1]:.{self.param_digits}f} \\cdot {self.X[1]:.1f} + {self.b1[0]:.{self.param_digits}f} = {self.z1[0]:.4f}",
1137
1130
  inline=False
@@ -1149,7 +1142,6 @@ class EndToEndTrainingQuestion(SimpleNeuralNetworkBase):
1149
1142
  ))
1150
1143
 
1151
1144
  # Output (pre-activation)
1152
- z2 = self.W2[0, 0] * self.a1[0] + self.W2[0, 1] * self.a1[1] + self.b2[0]
1153
1145
  explanation.add_element(ca.Equation(
1154
1146
  f"z_{{out}} = w_3 h_1 + w_4 h_2 + b_2 = {self.W2[0,0]:.{self.param_digits}f} \\cdot {self.a1[0]:.4f} + {self.W2[0,1]:.{self.param_digits}f} \\cdot {self.a1[1]:.4f} + {self.b2[0]:.{self.param_digits}f} = {self.z2[0]:.4f}",
1155
1147
  inline=False
@@ -4,3 +4,10 @@ from .tensorflow_questions import (
4
4
  RegularizationCalculationQuestion,
5
5
  MomentumOptimizerQuestion
6
6
  )
7
+
8
+ __all__ = [
9
+ "ParameterCountingQuestion",
10
+ "ActivationFunctionComputationQuestion",
11
+ "RegularizationCalculationQuestion",
12
+ "MomentumOptimizerQuestion",
13
+ ]
@@ -1,9 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import abc
4
- import io
5
3
  import logging
6
- import re
7
4
  import numpy as np
8
5
  import sympy as sp
9
6
  from typing import List, Tuple
@@ -900,7 +897,6 @@ class MomentumOptimizerQuestion(Question, TableQuestionMixin, BodyTemplatesMixin
900
897
  # Show calculation for each component
901
898
  digits = ca.Answer.DEFAULT_ROUNDING_DIGITS
902
899
  for i in range(context.num_variables):
903
- var_name = f"x_{i}"
904
900
  # Round all intermediate values to avoid floating point precision issues
905
901
  beta_times_v = round(context.momentum_beta * context.prev_velocity[i], digits)
906
902
  one_minus_beta = round(1 - context.momentum_beta, digits)
@@ -9,17 +9,20 @@ 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
- from io import BytesIO
17
+ import tempfile
18
+ import zlib
19
+ from datetime import datetime
18
20
  from pathlib import Path
19
21
  from typing import Optional, Dict, Any
20
22
 
21
23
  import segno
22
24
  from cryptography.fernet import Fernet
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
23
26
 
24
27
  log = logging.getLogger(__name__)
25
28
 
@@ -38,6 +41,22 @@ class QuestionQRCode:
38
41
 
39
42
  # Error correction level: M = 15% recovery (balanced for compact encoded data)
40
43
  ERROR_CORRECTION = 'M'
44
+ _generated_key: Optional[bytes] = None
45
+ V2_PREFIX = "v2."
46
+
47
+ @classmethod
48
+ def _persist_generated_key(cls, key: bytes) -> None:
49
+ try:
50
+ os.makedirs("out/keys", exist_ok=True)
51
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
52
+ path = os.path.join("out", "keys", f"quiz_encryption_key-{timestamp}.log")
53
+ with open(path, "w", encoding="utf-8") as handle:
54
+ handle.write("QUIZ_ENCRYPTION_KEY=")
55
+ handle.write(key.decode("ascii"))
56
+ handle.write("\n")
57
+ log.warning(f"Wrote generated QUIZ_ENCRYPTION_KEY to {path}")
58
+ except Exception as exc:
59
+ log.warning(f"Failed to persist generated QUIZ_ENCRYPTION_KEY: {exc}")
41
60
 
42
61
  @classmethod
43
62
  def get_encryption_key(cls) -> bytes:
@@ -61,67 +80,95 @@ class QuestionQRCode:
61
80
  "QUIZ_ENCRYPTION_KEY not set! Generating temporary key. "
62
81
  "Set this environment variable for production use!"
63
82
  )
64
- # Generate temporary key for development
65
- return Fernet.generate_key()
83
+ if cls._generated_key is None:
84
+ cls._generated_key = Fernet.generate_key()
85
+ os.environ["QUIZ_ENCRYPTION_KEY"] = cls._generated_key.decode("ascii")
86
+ cls._persist_generated_key(cls._generated_key)
87
+ return cls._generated_key
66
88
 
67
89
  # Key should be stored as base64 string in env
68
90
  return key_str.encode()
69
91
 
70
92
  @classmethod
71
- def encrypt_question_data(cls, question_type: str, seed: int, version: str,
93
+ def _derive_aead_key(cls, key: bytes) -> bytes:
94
+ return hashlib.sha256(key).digest()
95
+
96
+ @classmethod
97
+ def _encrypt_v2(cls, payload: Dict[str, Any], *, key: Optional[bytes] = None) -> str:
98
+ if key is None:
99
+ key = cls.get_encryption_key()
100
+ aead_key = cls._derive_aead_key(key)
101
+ aesgcm = AESGCM(aead_key)
102
+ nonce = os.urandom(12)
103
+ json_bytes = json.dumps(payload, separators=(',', ':'), ensure_ascii=False).encode("utf-8")
104
+ compressed = zlib.compress(json_bytes)
105
+ ciphertext = aesgcm.encrypt(nonce, compressed, None)
106
+ token = base64.urlsafe_b64encode(nonce + ciphertext).decode("ascii")
107
+ return f"{cls.V2_PREFIX}{token}"
108
+
109
+ @classmethod
110
+ def _decrypt_v2(cls, encrypted_data: str, *, key: Optional[bytes] = None) -> Dict[str, Any]:
111
+ if not encrypted_data.startswith(cls.V2_PREFIX):
112
+ raise ValueError("Not a v2 payload")
113
+ if key is None:
114
+ key = cls.get_encryption_key()
115
+ aead_key = cls._derive_aead_key(key)
116
+ token = encrypted_data[len(cls.V2_PREFIX):]
117
+ raw = base64.urlsafe_b64decode(token.encode("ascii"))
118
+ nonce, ciphertext = raw[:12], raw[12:]
119
+ aesgcm = AESGCM(aead_key)
120
+ compressed = aesgcm.decrypt(nonce, ciphertext, None)
121
+ json_bytes = zlib.decompress(compressed)
122
+ return json.loads(json_bytes.decode("utf-8"))
123
+
124
+ @classmethod
125
+ def encrypt_question_data(cls, question_type: str, seed: int, version: Optional[str] = None,
72
126
  config: Optional[Dict[str, Any]] = None,
127
+ context: Optional[Dict[str, Any]] = None,
128
+ points_value: Optional[float] = None,
73
129
  key: Optional[bytes] = None) -> str:
74
130
  """
75
- Encode question regeneration data with optional simple obfuscation.
131
+ Encode question regeneration data for QR embedding.
76
132
 
77
133
  Args:
78
134
  question_type: Class name of the question (e.g., "VectorDotProduct")
79
135
  seed: Random seed used to generate this specific question
80
- version: Question class version (e.g., "1.0")
136
+ version: Optional question version string
81
137
  config: Optional dictionary of configuration parameters
138
+ context: Optional dictionary of context extras
139
+ points_value: Optional points value (for redundancy)
82
140
  key: Encryption key (uses environment key if None)
83
141
 
84
142
  Returns:
85
- str: Base64-encoded (optionally XOR-obfuscated) data
143
+ str: Base64-encoded encrypted payload with v2 prefix
86
144
 
87
145
  Example:
88
146
  >>> encrypted = QuestionQRCode.encrypt_question_data("VectorDot", 12345, "1.0")
89
147
  >>> print(encrypted)
90
148
  'VmVjdG9yRG90OjEyMzQ1OjEuMA=='
91
149
  """
92
- # Create compact data string, including config if provided
150
+ payload: Dict[str, Any] = {
151
+ "t": question_type,
152
+ "s": seed,
153
+ }
154
+ if points_value is not None:
155
+ payload["p"] = points_value
93
156
  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
-
157
+ payload["c"] = config
158
+ if context:
159
+ payload["x"] = context
160
+ if version:
161
+ payload["v"] = version
162
+
163
+ encoded = cls._encrypt_v2(payload, key=key)
164
+ log.debug(f"Encoded question data v2: {question_type} seed={seed} ({len(encoded)} chars)")
118
165
  return encoded
119
166
 
120
167
  @classmethod
121
168
  def decrypt_question_data(cls, encrypted_data: str,
122
169
  key: Optional[bytes] = None) -> Dict[str, Any]:
123
170
  """
124
- Decode question regeneration data from QR code.
171
+ Decode question regeneration data from QR code (v2 preferred, v1 fallback).
125
172
 
126
173
  Args:
127
174
  encrypted_data: Base64-encoded (optionally XOR-obfuscated) string from QR code
@@ -142,10 +189,24 @@ class QuestionQRCode:
142
189
  key = cls.get_encryption_key()
143
190
 
144
191
  try:
145
- # Decode from base64
192
+ if encrypted_data.startswith(cls.V2_PREFIX):
193
+ payload = cls._decrypt_v2(encrypted_data, key=key)
194
+ result = {
195
+ "question_type": payload.get("t"),
196
+ "seed": int(payload.get("s")),
197
+ }
198
+ if "v" in payload:
199
+ result["version"] = payload.get("v")
200
+ if "c" in payload:
201
+ result["config"] = payload.get("c")
202
+ if "x" in payload:
203
+ result["context"] = payload.get("x")
204
+ if "p" in payload:
205
+ result["points"] = payload.get("p")
206
+ return result
207
+
208
+ # V1 fallback (XOR obfuscation)
146
209
  obfuscated = base64.urlsafe_b64decode(encrypted_data.encode())
147
-
148
- # Reverse XOR obfuscation if key is provided
149
210
  if key:
150
211
  key_bytes = key[:16] if isinstance(key, bytes) else key.encode()[:16]
151
212
  data_bytes = bytes(b ^ key_bytes[i % len(key_bytes)] for i, b in enumerate(obfuscated))
@@ -153,29 +214,26 @@ class QuestionQRCode:
153
214
  data_bytes = obfuscated
154
215
 
155
216
  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)}")
217
+ parts = data_str.split(':', 3)
218
+ if len(parts) < 2:
219
+ raise ValueError(f"Invalid encoded data format: expected at least 2 parts, got {len(parts)}")
161
220
 
162
221
  question_type = parts[0]
163
222
  seed_str = parts[1]
164
- version = parts[2]
223
+ version = parts[2] if len(parts) >= 3 else None
165
224
 
166
225
  result = {
167
226
  "question_type": question_type,
168
227
  "seed": int(seed_str),
169
- "version": version
170
228
  }
229
+ if version:
230
+ result["version"] = version
171
231
 
172
- # Parse config JSON if present
173
232
  if len(parts) == 4:
174
233
  try:
175
234
  result["config"] = json.loads(parts[3])
176
235
  except json.JSONDecodeError as e:
177
236
  log.warning(f"Failed to parse config JSON: {e}")
178
- # Continue without config rather than failing
179
237
 
180
238
  return result
181
239
 
@@ -215,24 +273,27 @@ class QuestionQRCode:
215
273
  """
216
274
  data = {
217
275
  "q": question_number,
218
- "pts": points_value
276
+ "p": points_value
219
277
  }
220
278
 
221
279
  # 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
280
+ if all(k in extra_data for k in ['question_type', 'seed']):
224
281
  config = extra_data.get('config', {})
282
+ context = extra_data.get('context', {})
225
283
  encrypted = cls.encrypt_question_data(
226
284
  extra_data['question_type'],
227
285
  extra_data['seed'],
228
- extra_data['version'],
229
- config=config
286
+ extra_data.get('version'),
287
+ config=config,
288
+ context=context,
289
+ points_value=points_value
230
290
  )
231
291
  data['s'] = encrypted
232
292
 
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']}
293
+ extra_data = {
294
+ k: v for k, v in extra_data.items()
295
+ if k not in ['question_type', 'seed', 'version', 'config', 'context']
296
+ }
236
297
 
237
298
  # Add any remaining extra metadata
238
299
  data.update(extra_data)
QuizGenerator/question.py CHANGED
@@ -11,16 +11,12 @@ import itertools
11
11
  import os
12
12
  import pathlib
13
13
  import pkgutil
14
- import pprint
15
14
  import random
16
- import re
17
15
  import uuid
18
16
  import types
19
17
  import inspect
20
18
  from types import MappingProxyType
21
19
 
22
- import pypandoc
23
- import yaml
24
20
  from typing import List, Dict, Any, Tuple, Optional, Mapping, MutableMapping
25
21
  import canvasapi.course, canvasapi.quiz
26
22
 
@@ -45,6 +41,7 @@ class RegenerationFlags:
45
41
  generation_seed: Optional[int]
46
42
  question_version: str
47
43
  config_params: Dict[str, Any]
44
+ context_extras: Dict[str, Any] = dataclasses.field(default_factory=dict)
48
45
 
49
46
 
50
47
  @dataclasses.dataclass(frozen=True)
@@ -94,7 +91,9 @@ class QuestionContext:
94
91
  return self.rng_seed
95
92
  if key == "rng":
96
93
  return self.rng
97
- return self.data.get(key, default)
94
+ if hasattr(self.data, "get"):
95
+ return self.data.get(key, default)
96
+ return default
98
97
 
99
98
  def __contains__(self, key: object) -> bool:
100
99
  if key in ("rng_seed", "rng"):
@@ -261,7 +260,12 @@ class QuestionRegistry:
261
260
  # Load modules from the current directory
262
261
  for _, module_name, _ in pkgutil.iter_modules([str(path)]):
263
262
  # Import the module
264
- module = importlib.import_module(f"{package_prefix}.{module_name}")
263
+ try:
264
+ importlib.import_module(f"{package_prefix}.{module_name}")
265
+ except ImportError as e:
266
+ log.warning(
267
+ f"Skipping module '{package_prefix}.{module_name}' due to import error: {e}"
268
+ )
265
269
 
266
270
  # Recursively load modules from subdirectories
267
271
  for subdir in path.iterdir():
@@ -614,12 +618,6 @@ class Question(abc.ABC):
614
618
  }
615
619
  self.config_params = {k: v for k, v in kwargs.items() if k not in framework_params}
616
620
 
617
- @classmethod
618
- def from_yaml(cls, path_to_yaml):
619
- with open(path_to_yaml) as fid:
620
- question_dicts = yaml.safe_load_all(fid)
621
-
622
-
623
621
  def instantiate(self, **kwargs) -> QuestionInstance:
624
622
  """
625
623
  Instantiate a question once, returning content, answers, and regeneration metadata.
@@ -688,6 +686,20 @@ class Question(abc.ABC):
688
686
  if isinstance(ctx, dict) and ctx.get("_config_params"):
689
687
  config_params.update(ctx.get("_config_params"))
690
688
 
689
+ context_extras: Dict[str, Any] = {}
690
+ if isinstance(ctx, QuestionContext):
691
+ include_list = ctx.get("qr_include_list", None)
692
+ if isinstance(include_list, (list, tuple)):
693
+ for key in include_list:
694
+ if key in ctx:
695
+ context_extras[key] = ctx[key]
696
+ elif isinstance(ctx, dict):
697
+ include_list = ctx.get("qr_include_list", None)
698
+ if isinstance(include_list, (list, tuple)):
699
+ for key in include_list:
700
+ if key in ctx:
701
+ context_extras[key] = ctx[key]
702
+
691
703
  instance = QuestionInstance(
692
704
  body=components.body,
693
705
  explanation=components.explanation,
@@ -701,7 +713,8 @@ class Question(abc.ABC):
701
713
  question_class_name=self._get_registered_name(),
702
714
  generation_seed=actual_seed,
703
715
  question_version=self.VERSION,
704
- config_params=config_params
716
+ config_params=config_params,
717
+ context_extras=context_extras
705
718
  )
706
719
  )
707
720
  return instance
@@ -932,6 +945,7 @@ class Question(abc.ABC):
932
945
  question_ast.generation_seed = instance.flags.generation_seed
933
946
  question_ast.question_version = instance.flags.question_version
934
947
  question_ast.config_params = dict(instance.flags.config_params)
948
+ question_ast.qr_context_extras = dict(instance.flags.context_extras)
935
949
 
936
950
  return question_ast
937
951
 
@@ -1014,11 +1028,11 @@ class QuestionGroup():
1014
1028
 
1015
1029
  def instantiate(self, *args, **kwargs):
1016
1030
 
1017
- # todo: Make work with rng_seed (or at least verify)
1018
- random.seed(kwargs.get("rng_seed", None))
1031
+ # Use a local RNG to avoid global side effects.
1032
+ rng = random.Random(kwargs.get("rng_seed", None))
1019
1033
 
1020
1034
  if not self.pick_once or self._current_question is None:
1021
- self._current_question = random.choice(self.questions)
1035
+ self._current_question = rng.choice(self.questions)
1022
1036
 
1023
1037
  def __getattr__(self, name):
1024
1038
  if self._current_question is None or name == "generate":
QuizGenerator/quiz.py CHANGED
@@ -2,15 +2,10 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import collections
5
- import itertools
6
5
  import logging
7
- import os.path
8
6
  import random
9
- import shutil
10
- import subprocess
11
- import tempfile
12
7
  from datetime import datetime
13
- from typing import List, Dict, Optional
8
+ from typing import List
14
9
  import re
15
10
 
16
11
  import yaml