quizcomp.model.answer

   1import abc
   2import enum
   3import math
   4import random
   5import typing
   6
   7import edq.util.enum
   8import edq.util.parse
   9import edq.util.serial
  10
  11import quizcomp.model.constants
  12import quizcomp.model.errors
  13import quizcomp.model.feedback
  14
  15class NumericAnswerType(enum.Enum):
  16    """ The types of numeric answers supported by the Quiz Composer. """
  17
  18    EXACT = 'exact'
  19    RANGE = 'range'
  20    PRECISION = 'precision'
  21
  22class TextOption(edq.util.serial.PODConverter):
  23    """
  24    One possible text answer to a question.
  25    """
  26
  27    serialization_omit_none = True
  28    serialization_omit_empty = True
  29
  30    def __init__(self,
  31            text: quizcomp.parser.document.ParsedDocument,
  32            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
  33            **kwargs: typing.Any) -> None:
  34        self.text: quizcomp.parser.document.ParsedDocument = text
  35        """ The text/label for this choice. """
  36
  37        if ((feedback is not None) and feedback.is_empty()):
  38            feedback = None
  39
  40        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
  41        """ Feedback specific to this choice. """
  42
  43    def to_pod(self,
  44            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
  45            ) -> edq.util.serial.PODType:
  46        if (self.feedback is None):
  47            return self.text.to_pod(context)
  48
  49        return {
  50            'text': self.text.to_pod(context),
  51            'feedback': self.feedback.to_pod(context),
  52        }
  53
  54    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
  55        """ Collect all documents in this object. """
  56
  57        documents = [self.text]
  58
  59        if (self.feedback is not None):
  60            documents += self.feedback.collect_documents()
  61
  62        return documents
  63
  64    @classmethod
  65    def from_pod(cls: typing.Type['TextOption'],
  66            data: edq.util.serial.PODType,
  67            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
  68            ) -> 'TextOption':
  69        if (context is None):
  70            context = edq.util.serial.SerializationContext()
  71
  72        label = context.extra.get('label', '')
  73
  74        if (isinstance(data, str)):
  75            parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context)
  76            return TextOption(parsed_text, None)
  77
  78        if (not isinstance(data, dict)):
  79            raise quizcomp.model.errors.QuestionValidationError(
  80                f"{label} has text in an unknown format (not a string or dict): '{data}' (type: {type(data)}.",
  81                context = context)
  82
  83        raw_text = data.get('text', None)
  84        if (raw_text is None):
  85            raise quizcomp.model.errors.QuestionValidationError(
  86                    f"{label} has no 'text' field set.",
  87                    context = context)
  88
  89        parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(str(raw_text), context)
  90        feedback = quizcomp.model.feedback.Feedback.from_raw_data(data.get('feedback', None), context)
  91
  92        return TextOption(parsed_text, feedback)
  93
  94    @classmethod
  95    def from_pod_with_error(cls: typing.Type['TextOption'],
  96            data: edq.util.serial.PODType,
  97            label: str,
  98            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
  99            ) -> 'TextOption':
 100        """ Wrap from_pod() with some error information. """
 101
 102        if (context is None):
 103            context = edq.util.serial.SerializationContext()
 104        else:
 105            context = context.copy()
 106
 107        context.extra['label'] = label
 108
 109        return cls.from_pod(data, context)
 110
 111class NumericOption(edq.util.serial.PODConverter, abc.ABC):
 112    """
 113    One possible numeric answer to a question.
 114    """
 115
 116    serialization_omit_none = True
 117    serialization_omit_empty = True
 118
 119    def __init__(self,
 120            type: NumericAnswerType,
 121            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
 122            **kwargs: typing.Any) -> None:
 123        self.type: NumericAnswerType = type
 124        """ The type of numeric answer. """
 125
 126        if ((feedback is not None) and feedback.is_empty()):
 127            feedback = None
 128
 129        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
 130        """ Feedback specific to this choice. """
 131
 132    @abc.abstractmethod
 133    def to_text(self) -> TextOption:
 134        """ Get a textual representation of this option. """
 135
 136    @classmethod
 137    def from_pod(cls: typing.Type['NumericOption'],
 138            data: edq.util.serial.PODType,
 139            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 140            ) -> 'NumericOption':
 141        if (context is None):
 142            context = edq.util.serial.SerializationContext()
 143
 144        label = context.extra.get('label', '')
 145
 146        quizcomp.model.errors.check_type(data, dict, label, context = context)
 147        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
 148
 149        raw_answer_type = dict_data.get('type', None)
 150        if (not edq.util.enum.has_value(NumericAnswerType, raw_answer_type)):
 151            raise quizcomp.model.errors.QuestionValidationError(
 152                    f"{label} has an unknown answer type: '{raw_answer_type}'.",
 153                    context = context)
 154
 155        answer_type = NumericAnswerType(raw_answer_type)
 156
 157        feedback = quizcomp.model.feedback.Feedback.from_raw_data(dict_data.get('feedback', None), context = context)
 158
 159        if (answer_type == NumericAnswerType.EXACT):
 160            value = dict_data.get('value', None)
 161            if (value is None):
 162                raise quizcomp.model.errors.QuestionValidationError(
 163                        f"{label} does not have a required 'value' key.",
 164                        context = context)
 165
 166            if (not isinstance(value, (int, float))):
 167                raise quizcomp.model.errors.QuestionValidationError(
 168                        f"{label} has a 'value' that is not an int or float, found '{type(value)}'.",
 169                        context = context)
 170
 171            margin = dict_data.get('margin', 0.0)
 172            if (not isinstance(margin, (int, float))):
 173                raise quizcomp.model.errors.QuestionValidationError(
 174                        f"{label} has a 'margin' that is not an int or float, found '{type(margin)}'.",
 175                        context = context)
 176
 177            return NumericOptionExact(value, margin, feedback = feedback)
 178        elif (answer_type == NumericAnswerType.RANGE):
 179            min = dict_data.get('min', None)
 180            if (min is None):
 181                raise quizcomp.model.errors.QuestionValidationError(
 182                        f"{label} does not have a required 'min' key.",
 183                        context = context)
 184
 185            if (not isinstance(min, (int, float))):
 186                raise quizcomp.model.errors.QuestionValidationError(
 187                        f"{label} has a 'min' that is not an int or float, found '{type(min)}'.",
 188                        context = context)
 189
 190            max = dict_data.get('max', None)
 191            if (max is None):
 192                raise quizcomp.model.errors.QuestionValidationError(
 193                        f"{label} does not have a required 'max' key.",
 194                        context = context)
 195
 196            if (not isinstance(max, (int, float))):
 197                raise quizcomp.model.errors.QuestionValidationError(
 198                        f"{label} has a 'max' that is not an int or float, found '{type(max)}'.",
 199                        context = context)
 200
 201            return NumericOptionRange(min, max, feedback = feedback)
 202        elif (answer_type == NumericAnswerType.PRECISION):
 203            value = dict_data.get('value', None)
 204            if (value is None):
 205                raise quizcomp.model.errors.QuestionValidationError(
 206                        f"{label} does not have a required 'value' key.",
 207                        context = context)
 208
 209            if (not isinstance(value, (int, float))):
 210                raise quizcomp.model.errors.QuestionValidationError(
 211                        f"{label} has a 'value' that is not an int or float, found '{type(value)}'.",
 212                        context = context)
 213
 214            precision = dict_data.get('precision', None)
 215            if (precision is None):
 216                raise quizcomp.model.errors.QuestionValidationError(
 217                        f"{label} does not have a required 'precision' key.",
 218                        context = context)
 219
 220            if (not isinstance(precision, int)):
 221                raise quizcomp.model.errors.QuestionValidationError(
 222                        f"{label} has a 'precision' that is not an int, found '{type(precision)}'.",
 223                        context = context)
 224
 225            return NumericOptionPrecision(value, precision, feedback = feedback)
 226        else:
 227            raise quizcomp.model.errors.QuestionValidationError(
 228                    f"{label} has an unknown answer type: '{answer_type}'.",
 229                    context = context)
 230
 231    @classmethod
 232    def from_pod_with_error(cls: typing.Type['NumericOption'],
 233            data: edq.util.serial.PODType,
 234            label: str,
 235            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 236            ) -> 'NumericOption':
 237        """ Wrap from_pod() with some error information. """
 238
 239        if (context is None):
 240            context = edq.util.serial.SerializationContext()
 241        else:
 242            context = context.copy()
 243
 244        context.extra['label'] = label
 245
 246        return cls.from_pod(data, context)
 247
 248class NumericOptionExact(NumericOption):
 249    """ A numeric option to represent an exact value (within a margin). """
 250
 251    def __init__(self,
 252            value: typing.Union[float, int],
 253            margin: typing.Union[float, int] = 0.0,
 254            **kwargs: typing.Any) -> None:
 255        super().__init__(type = NumericAnswerType.EXACT, **kwargs)
 256
 257        self.value: typing.Union[float, int] = value
 258        """ The value for this answer. """
 259
 260        self.margin: typing.Union[float, int] = margin
 261        """ The allowed margin or error for this answer. """
 262
 263    def to_text(self) -> TextOption:
 264        text = str(self.value)
 265        if (not math.isclose(self.margin, 0.0)):
 266            text += f" ± {self.margin}"
 267
 268        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)
 269
 270class NumericOptionRange(NumericOption):
 271    """ A numeric option to represent a value within a range. """
 272
 273    def __init__(self,
 274            min: typing.Union[float, int],
 275            max: typing.Union[float, int],
 276            **kwargs: typing.Any) -> None:
 277        super().__init__(type = NumericAnswerType.RANGE, **kwargs)
 278
 279        self.min: typing.Union[float, int] = min
 280        """ The minimum allowed value. """
 281
 282        self.max: typing.Union[float, int] = max
 283        """ The maximum allowed value. """
 284
 285    def to_text(self) -> TextOption:
 286        text = f"[{self.min}, {self.max}]"
 287        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)
 288
 289class NumericOptionPrecision(NumericOption):
 290    """ A numeric option to represent a value within a specified order of magnitudes. """
 291
 292    def __init__(self,
 293            value: typing.Union[float, int],
 294            precision: int,
 295            **kwargs: typing.Any) -> None:
 296        super().__init__(type = NumericAnswerType.PRECISION, **kwargs)
 297
 298        self.value: typing.Union[float, int] = value
 299        """ The value for this answer. """
 300
 301        self.precision: int = precision
 302        """ The number of order of magnitudes allowed. """
 303
 304    def to_text(self) -> TextOption:
 305        text = str(self.value)
 306        if (self.precision != 1):
 307            text += f" ({self.precision} decimal places)"
 308
 309        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)
 310
 311class Choice(TextOption):
 312    """
 313    One possible choice for an answer.
 314    This is for questions with a finite number of choices (e.g., MCQ, MA, TF).
 315    """
 316
 317    serialization_omit_none = True
 318
 319    def __init__(self,
 320            text: quizcomp.parser.document.ParsedDocument,
 321            correct: bool,
 322            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
 323            **kwargs: typing.Any) -> None:
 324        super().__init__(text, feedback)
 325
 326        self.correct: bool = correct
 327        """ Whether this choice is a correct answer. """
 328
 329    def to_pod(self,
 330            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 331            ) -> edq.util.serial.PODType:
 332        data: typing.Dict[str, edq.util.serial.PODType] = {
 333            'text': self.text.to_pod(context),
 334            'correct': self.correct,
 335        }
 336
 337        if (self.feedback is not None):
 338            data['feedback'] = self.feedback.to_pod(context)
 339
 340        return data
 341
 342class QuestionAnswers(edq.util.serial.PODConverter):
 343    """
 344    The base type that represents all the listed answers/choices for a question.
 345    The exact contents of answers vary depending on the question's type.
 346    """
 347
 348    def shuffle(self, rng: random.Random) -> None:
 349        """ Shuffle the choices/options (if applicable). """
 350
 351    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 352        """ Collect all documents in this object. """
 353
 354        return []
 355
 356    @classmethod
 357    def from_pod(cls: typing.Type['QuestionAnswers'],
 358            data: edq.util.serial.PODType,
 359            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 360            ) -> 'QuestionAnswers':
 361        """
 362        Create an answers object for a specific question type from some serialized data
 363        This data will normally come from a JSON file.
 364        Because of the differing nature of questions, several different forms of answers may need to be processed
 365        (even for the same question type).
 366
 367        This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
 368        """
 369
 370        if (context is None):
 371            context = edq.util.serial.SerializationContext()
 372
 373        raw_question_type = context.extra.get('question_type', None)
 374
 375        if (raw_question_type is None):
 376            raise quizcomp.model.errors.QuestionValidationError(
 377                    "Could not parse question answers because of lack of question type.",
 378                    context = context)
 379
 380        question_type = quizcomp.model.constants.QuestionType(raw_question_type)
 381
 382        if (question_type == quizcomp.model.constants.QuestionType.ESSAY):
 383            return TextAnswers.from_pod(data, context)
 384        elif (question_type == quizcomp.model.constants.QuestionType.FIMB):
 385            return MultiplePartTextAnswers.from_pod(data, context)
 386        elif (question_type == quizcomp.model.constants.QuestionType.FITB):
 387            return TextAnswers.from_pod(data, context)
 388        elif (question_type == quizcomp.model.constants.QuestionType.MA):
 389            context = context.copy()
 390            context.extra['min_correct'] = 0
 391            return ChoiceAnswers.from_pod(data, context)
 392        elif (question_type == quizcomp.model.constants.QuestionType.MATCHING):
 393            return MatchingAnswers.from_pod(data, context)
 394        elif (question_type == quizcomp.model.constants.QuestionType.MCQ):
 395            context = context.copy()
 396            context.extra['min_correct'] = 1
 397            context.extra['max_correct'] = 1
 398            return ChoiceAnswers.from_pod(data, context)
 399        elif (question_type == quizcomp.model.constants.QuestionType.MDD):
 400            context = context.copy()
 401            context.extra['min_correct'] = 1
 402            context.extra['max_correct'] = 1
 403            return MultiplePartChoiceAnswers.from_pod(data, context)
 404        elif (question_type == quizcomp.model.constants.QuestionType.NUMERICAL):
 405            return NumericAnswers.from_pod(data, context)
 406        elif (question_type == quizcomp.model.constants.QuestionType.SA):
 407            return TextAnswers.from_pod(data, context)
 408        elif (question_type == quizcomp.model.constants.QuestionType.TEXT_ONLY):
 409            return TextAnswers.from_pod(data, context)
 410        elif (question_type == quizcomp.model.constants.QuestionType.TF):
 411            return TFAnswers.from_pod(data, context)
 412        else:
 413            raise quizcomp.model.errors.QuestionValidationError(
 414                    f"Unknown question type: '{raw_question_type}'.",
 415                    context = context)
 416
 417class TextAnswers(QuestionAnswers):
 418    """
 419    Answers that include a list of possible text options.
 420    Note that the text options are not choices (i.e., they are not presented in a multiple choice fashion),
 421    instead they are possible answers.
 422    Question types with this type of answers are often graded via text equality or manually
 423    (where these answers would serve as a guide/rubric).
 424    """
 425
 426    def __init__(self,
 427            options: typing.Union[typing.List[TextOption], None] = None,
 428            **kwargs: typing.Any) -> None:
 429        super().__init__(**kwargs)
 430
 431        if (options is None):
 432            options = []
 433
 434        self.options: typing.List[TextOption] = options
 435        """ The possible text options. """
 436
 437    def shuffle(self, rng: random.Random) -> None:
 438        rng.shuffle(self.options)
 439
 440    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 441        documents = []
 442
 443        for option in self.options:
 444            documents += option.collect_documents()
 445
 446        return documents
 447
 448    def to_pod(self,
 449            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 450            ) -> edq.util.serial.PODType:
 451        return [option.to_pod(context) for option in self.options]
 452
 453    def _serialization_is_empty(self) -> bool:
 454        """ A special method for the serialization library to check. """
 455
 456        return (len(self.options) == 0)
 457
 458    def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
 459        """
 460        Get the text document for the first option.
 461        If there is no option, return an empty document.
 462        """
 463
 464        if (len(self.options) == 0):
 465            return quizcomp.parser.document.ParsedDocument()
 466
 467        return self.options[0].text
 468
 469    @classmethod
 470    def from_pod(cls: typing.Type['TextAnswers'],
 471            data: edq.util.serial.PODType,
 472            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 473            ) -> 'TextAnswers':
 474        if (data is None):
 475            return TextAnswers()
 476
 477        if (context is None):
 478            context = edq.util.serial.SerializationContext()
 479
 480        if (isinstance(data, str)):
 481            parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context)
 482            return TextAnswers([TextOption(parsed_text, None)])
 483
 484        if (isinstance(data, dict)):
 485            data = [data]
 486
 487        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
 488        list_data = typing.cast(typing.List[edq.util.serial.PODType], data)
 489
 490        if (len(list_data) == 0):
 491            return TextAnswers()
 492
 493        options = []
 494        for (i, raw_option) in enumerate(list_data):
 495            label = f"Choice at index {i}"
 496
 497            option = TextOption.from_pod_with_error(raw_option, label, context)
 498            options.append(option)
 499
 500        return TextAnswers(options)
 501
 502class MultiplePartTextAnswers(QuestionAnswers):
 503    """
 504    Answers that have multiple parts, each having their own text-based answers.
 505    """
 506
 507    def __init__(self,
 508            parts: typing.Dict[str, TextAnswers],
 509            *args: typing.Any,
 510            **kwargs: typing.Any) -> None:
 511        super().__init__(*args, **kwargs)
 512
 513        self.parts: typing.Dict[str, TextAnswers] = parts
 514        """ The different parts of this question. """
 515
 516    def shuffle(self, rng: random.Random) -> None:
 517        for part in self.parts.values():
 518            part.shuffle(rng)
 519
 520    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 521        documents = []
 522
 523        for part in self.parts.values():
 524            documents += part.collect_documents()
 525
 526        return documents
 527
 528    def to_pod(self,
 529            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 530            ) -> edq.util.serial.PODType:
 531        return {key: value.to_pod(context) for (key, value) in self.parts.items()}
 532
 533    @classmethod
 534    def from_pod(cls: typing.Type['MultiplePartTextAnswers'],
 535            data: edq.util.serial.PODType,
 536            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 537            ) -> 'MultiplePartTextAnswers':
 538        if (context is None):
 539            context = edq.util.serial.SerializationContext()
 540
 541        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
 542        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
 543
 544        parts = {}
 545        for (key, raw_options) in dict_data.items():
 546            # Try to parse the key, even though we are not storing it right now.
 547            quizcomp.parser.document.ParsedDocument.parse_text(key, context)
 548
 549            parts[key] = TextAnswers.from_pod(raw_options, context)
 550
 551        return MultiplePartTextAnswers(parts)
 552
 553class ChoiceAnswers(QuestionAnswers):
 554    """ Answers that include a finite set of choices. """
 555
 556    def __init__(self,
 557            choices: typing.List[Choice],
 558            *args: typing.Any,
 559            **kwargs: typing.Any) -> None:
 560        super().__init__(*args, **kwargs)
 561
 562        self.choices: typing.List[Choice] = choices
 563        """ The possible choices. """
 564
 565    def shuffle(self, rng: random.Random) -> None:
 566        rng.shuffle(self.choices)
 567
 568    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 569        documents = []
 570
 571        for choice in self.choices:
 572            documents += choice.collect_documents()
 573
 574        return documents
 575
 576    def to_pod(self,
 577            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 578            ) -> edq.util.serial.PODType:
 579        return [choice.to_pod(context) for choice in self.choices]
 580
 581    def get_choices_with_markers(self) -> typing.List[typing.Tuple[quizcomp.parser.document.ParsedDocument, Choice]]:
 582        """ Get the choices for this answer along with markers for each (e.g., "A", "B", "C"). """
 583
 584        return [
 585            (quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]), choice)
 586            for (i, choice)
 587            in enumerate(self.choices)
 588        ]
 589
 590    @classmethod
 591    def from_pod(cls: typing.Type['ChoiceAnswers'],
 592            data: edq.util.serial.PODType,
 593            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 594            ) -> 'ChoiceAnswers':
 595        if (context is None):
 596            context = edq.util.serial.SerializationContext()
 597
 598        min_correct = context.extra.get('min_correct', 0)
 599        max_correct = context.extra.get('max_correct', quizcomp.model.constants.MAX_CHOICES)
 600        min_incorrect = context.extra.get('min_incorrect', 0)
 601        max_incorrect = context.extra.get('max_incorrect', quizcomp.model.constants.MAX_CHOICES)
 602
 603        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
 604        raw_choices = typing.cast(typing.List[edq.util.serial.PODType], data)
 605
 606        if (len(raw_choices) == 0):
 607            raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context)
 608
 609        num_correct = 0
 610        num_incorrect = 0
 611
 612        choices = []
 613
 614        for (i, raw_choice) in enumerate(raw_choices):
 615            label = f"Choice at index {i}"
 616
 617            quizcomp.model.errors.check_type(raw_choice, dict, label, context = context)
 618            choice_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], raw_choice)
 619
 620            raw_correct = choice_data.get('correct', None)
 621            if (raw_correct is None):
 622                raise quizcomp.model.errors.QuestionValidationError(f"{label} has no 'correct' field set.", context = context)
 623
 624            correct = edq.util.parse.soft_boolean(raw_correct)
 625            if (correct is None):
 626                raise quizcomp.model.errors.QuestionValidationError(
 627                        f"{label}'s 'correct' field does not contain a boolean: '{raw_correct}'.",
 628                        context = context)
 629
 630            if (correct):
 631                num_correct += 1
 632            else:
 633                num_incorrect += 1
 634
 635            option = TextOption.from_pod_with_error(choice_data, label, context)
 636            choices.append(Choice(option.text, correct, option.feedback))
 637
 638        if (num_correct < min_correct):
 639            raise quizcomp.model.errors.QuestionValidationError(("Did not find enough correct choices."
 640                + f" Expected at least {min_correct}, found {num_correct}."),
 641                context = context)
 642
 643        if (num_correct > max_correct):
 644            raise quizcomp.model.errors.QuestionValidationError(("Found too many correct choices."
 645                + f" Expected at most {max_correct}, found {num_correct}."),
 646                context = context)
 647
 648        if (num_incorrect < min_incorrect):
 649            raise quizcomp.model.errors.QuestionValidationError(("Did not find enough incorrect choices."
 650                + f" Expected at least {min_incorrect}, found {num_incorrect}."),
 651                context = context)
 652
 653        if (num_incorrect > max_incorrect):
 654            raise quizcomp.model.errors.QuestionValidationError(("Found too many incorrect choices."
 655                + f" Expected at most {max_incorrect}, found {num_incorrect}."),
 656                context = context)
 657
 658        return ChoiceAnswers(choices)
 659
 660class TFAnswers(ChoiceAnswers):
 661    """ Answers that must be true or false. """
 662
 663    def __init__(self,
 664            *args: typing.Any,
 665            **kwargs: typing.Any) -> None:
 666        super().__init__(*args, **kwargs)
 667
 668    @classmethod
 669    def from_pod(cls: typing.Type['TFAnswers'],
 670            data: edq.util.serial.PODType,
 671            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 672            ) ->  'TFAnswers':
 673        if (context is None):
 674            context = edq.util.serial.SerializationContext()
 675        else:
 676            context = context.copy()
 677
 678        if (isinstance(data, bool)):
 679            choices = [
 680                Choice(quizcomp.parser.document.ParsedDocument.parse_text("True", context), data),
 681                Choice(quizcomp.parser.document.ParsedDocument.parse_text("False", context), (not data)),
 682            ]
 683            return TFAnswers(choices)
 684
 685        context.extra['min_correct'] = 1
 686        context.extra['max_correct'] = 1
 687        context.extra['min_incorrect'] = 1
 688        context.extra['max_incorrect'] = 1
 689
 690        answers = ChoiceAnswers.from_pod(data, context)
 691
 692        return TFAnswers(answers.choices)
 693
 694class MultiplePartChoiceAnswers(QuestionAnswers):
 695    """
 696    Answers that have multiple parts, each having their own choice-based answers.
 697    """
 698
 699    def __init__(self,
 700            parts: typing.Dict[str, ChoiceAnswers],
 701            *args: typing.Any,
 702            **kwargs: typing.Any) -> None:
 703        super().__init__(*args, **kwargs)
 704
 705        self.parts: typing.Dict[str, ChoiceAnswers] = parts
 706        """ The different parts of this question. """
 707
 708    def shuffle(self, rng: random.Random) -> None:
 709        for part in self.parts.values():
 710            part.shuffle(rng)
 711
 712    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 713        documents = []
 714
 715        for part in self.parts.values():
 716            documents += part.collect_documents()
 717
 718        return documents
 719
 720    def to_pod(self,
 721            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 722            ) -> edq.util.serial.PODType:
 723        return {key: value.to_pod(context) for (key, value) in self.parts.items()}
 724
 725    @classmethod
 726    def from_pod(cls: typing.Type['MultiplePartChoiceAnswers'],
 727            data: edq.util.serial.PODType,
 728            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 729            ) -> 'MultiplePartChoiceAnswers':
 730        if (context is None):
 731            context = edq.util.serial.SerializationContext()
 732
 733        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
 734        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
 735
 736        parts = {}
 737        for (key, raw_options) in dict_data.items():
 738            # Try to parse the key, even though we are not storing it right now.
 739            quizcomp.parser.document.ParsedDocument.parse_text(key, context)
 740
 741            parts[key] = ChoiceAnswers.from_pod(raw_options, context)
 742
 743        return MultiplePartChoiceAnswers(parts)
 744
 745class MatchingAnswerRow:
 746    """
 747    A single "row" when writing out a matching problem as a table.
 748    """
 749
 750    def __init__(self,
 751            left: typing.Union[TextOption, None],
 752            right: TextOption,
 753            right_marker: quizcomp.parser.document.ParsedDocument,
 754            correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None],
 755            correct_option: typing.Union[TextOption, None],
 756            ) -> None:
 757        self.left: typing.Union[TextOption, None] = left
 758        """
 759        The query part of the match that needs to find its partner.
 760        This may be None if there are distractors.
 761        """
 762
 763        self.right: TextOption = right
 764        """
 765        The target part of the match.
 766        Note that this may NOT be the correct partner to `self.left`,
 767        it is just the target that should appear on the same row.
 768        """
 769
 770        self.right_marker: quizcomp.parser.document.ParsedDocument = right_marker
 771        """
 772        The marker that accompanies this target.
 773        """
 774
 775        self.correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None] = correct_marker
 776        """
 777        The marker for the correct partner to `self.left`.
 778        Will be None if `self.left` is None.
 779        """
 780
 781        self.correct_option: typing.Union[TextOption, None] = correct_option
 782        """
 783        The text for the correct partner to `self.left`.
 784        Will be None if `self.left` is None.
 785        """
 786
 787class MatchingAnswers(QuestionAnswers):
 788    """ Answers for matching-type questions. """
 789
 790    serialization_omit_empty = True
 791    serialization_skip_fields = {
 792        '_shuffle_seed',
 793    }
 794
 795    def __init__(self,
 796            pairs: typing.List[typing.Tuple[TextOption, TextOption]],
 797            distractors: typing.Union[typing.List[TextOption], None] = None,
 798            **kwargs: typing.Any) -> None:
 799        super().__init__(**kwargs)
 800
 801        self.pairs: typing.List[typing.Tuple[TextOption, TextOption]] = pairs
 802        """ The matching pairs of items. """
 803
 804        if (distractors is None):
 805            distractors = []
 806
 807        self.distractors: typing.List[TextOption] = distractors
 808        """ Extra options to serve as a distraction. """
 809
 810        self._shuffle_seed: typing.Union[int, None] = None
 811        """
 812        A seed to use when shuffling the left and right sides.
 813
 814        This will be set in shuffle().
 815        A None value indicates that no shuffling will occur.
 816        """
 817
 818    def shuffle(self, rng: random.Random) -> None:
 819        rng.shuffle(self.pairs)
 820        rng.shuffle(self.distractors)
 821        self._shuffle_seed = rng.randint(0, 2**64)
 822
 823    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 824        documents = []
 825
 826        for (left, right) in self.pairs:
 827            documents += left.collect_documents()
 828            documents += right.collect_documents()
 829
 830        for distractor in self.distractors:
 831            documents += distractor.collect_documents()
 832
 833        return documents
 834
 835    def to_pod(self,
 836            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 837            ) -> edq.util.serial.PODType:
 838        return {
 839            'matches': [[left.to_pod(context), right.to_pod(context)] for (left, right) in self.pairs],
 840            'distractors': [value.to_pod(context) for value in self.distractors],
 841        }
 842
 843    def get_tabular_options(self) -> typing.List[MatchingAnswerRow]:
 844        """
 845        Get all the matching options laid out in a table.
 846
 847        If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table.
 848        """
 849
 850        lefts = []
 851        rights = []
 852
 853        for (pair_left, pair_right) in self.pairs:
 854            lefts.append(pair_left)
 855            rights.append(pair_right)
 856
 857        for distractor in self.distractors:
 858            rights.append(distractor)
 859
 860        # The ordered indexes to use in the options table.
 861        # This may be shuffled.
 862        left_indexes = list(range(len(lefts)))
 863        right_indexes = list(range(len(rights)))
 864
 865        if (self._shuffle_seed is not None):
 866            rng = random.Random(self._shuffle_seed)
 867            rng.shuffle(left_indexes)
 868            rng.shuffle(right_indexes)
 869
 870        options = []
 871        for (i, right_index) in enumerate(right_indexes):
 872            right = rights[right_index]
 873            right_marker = quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i])
 874
 875            left: typing.Union[TextOption, None] = None
 876            left_marker = None
 877            correct_answer = None
 878            if (i < len(left_indexes)):
 879                left_index = left_indexes[i]
 880
 881                # Find the correct marker index for this left by looking up the matching index in the right indexes.
 882                matching_right_marker_index = right_indexes.index(left_index)
 883
 884                left = lefts[left_index]
 885                left_marker = quizcomp.parser.document.ParsedDocument.parse_text(
 886                        quizcomp.model.constants.DEFAULT_CHOICES[matching_right_marker_index])
 887                correct_answer = rights[left_index]
 888
 889            options.append(MatchingAnswerRow(left, right, right_marker, left_marker, correct_answer))
 890
 891        return options
 892
 893    @classmethod
 894    def from_pod(cls: typing.Type['MatchingAnswers'],
 895            data: edq.util.serial.PODType,
 896            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 897            ) -> 'MatchingAnswers':
 898        if (context is None):
 899            context = edq.util.serial.SerializationContext()
 900
 901        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
 902        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
 903
 904        raw_matches = dict_data.get('matches', None)
 905        if (raw_matches is None):
 906            raise quizcomp.model.errors.QuestionValidationError(
 907                    "The 'matches' key was not provided for a matching-type question.",
 908                    context = context)
 909
 910        quizcomp.model.errors.check_type(raw_matches, list, "'matches'", context = context)
 911        matches = typing.cast(typing.List[edq.util.serial.PODType], raw_matches)
 912
 913        if (len(matches) == 0):
 914            raise quizcomp.model.errors.QuestionValidationError(
 915                    "At least one matching pair must be specified for matching questions.",
 916                    context = context)
 917
 918        pairs = []
 919        for (i, raw_match) in enumerate(matches):
 920            label = f"Match pair at index {i}"
 921
 922            if (isinstance(raw_match, list)):
 923                if (len(raw_match) != 2):
 924                    raise quizcomp.model.errors.QuestionValidationError(
 925                        f"{label} has an unexpected size. Expecting two items (left and right) found {len(raw_match)}.",
 926                        context = context)
 927
 928                left_option = TextOption.from_pod_with_error(raw_match[0], label + ' (left)', context)
 929                right_option = TextOption.from_pod_with_error(raw_match[1], label + ' (right)', context)
 930
 931                pairs.append((left_option, right_option))
 932            elif (isinstance(raw_match, dict)):
 933                if ('left' not in raw_match):
 934                    raise quizcomp.model.errors.QuestionValidationError(
 935                        f"{label} does not have a 'left' key.",
 936                        context = context)
 937
 938                if ('right' not in raw_match):
 939                    raise quizcomp.model.errors.QuestionValidationError(
 940                        f"{label} does not have a 'right' key.",
 941                        context = context)
 942
 943                left_option = TextOption.from_pod_with_error(raw_match['left'], label + ' (left)', context)
 944                right_option = TextOption.from_pod_with_error(raw_match['right'], label + ' (right)', context)
 945
 946                pairs.append((left_option, right_option))
 947            else:
 948                raise quizcomp.model.errors.QuestionValidationError(
 949                    f"{label} has an unknown format (not a list or dict): '{raw_match}' (type: {type(raw_match)}.",
 950                    context = context)
 951
 952        raw_distractors = dict_data.get('distractors', None)
 953        if (raw_distractors is None):
 954            raw_distractors = []
 955
 956        quizcomp.model.errors.check_type(raw_distractors, list, "'distractors'", context = context)
 957        distractors = typing.cast(typing.List[edq.util.serial.PODType], raw_distractors)
 958
 959        claen_distractors = []
 960        for (i, raw_distractor) in enumerate(distractors):
 961            label = f"Match distractor at index {i}"
 962
 963            option = TextOption.from_pod_with_error(raw_distractor, label, context)
 964            claen_distractors.append(option)
 965
 966        if ((len(pairs) + len(claen_distractors)) > quizcomp.model.constants.MAX_CHOICES):
 967            raise quizcomp.model.errors.QuestionValidationError(
 968                (f"Matching question has too many options. Found {(len(pairs) + len(claen_distractors))},"
 969                f" while the max is {quizcomp.model.constants.MAX_CHOICES}."),
 970                context = context)
 971
 972        return MatchingAnswers(pairs, claen_distractors)
 973
 974class NumericAnswers(QuestionAnswers):
 975    """ Answers that include a finite set of numeric options. """
 976
 977    def __init__(self,
 978            options: typing.List[NumericOption],
 979            *args: typing.Any,
 980            **kwargs: typing.Any) -> None:
 981        super().__init__(*args, **kwargs)
 982
 983        self.options: typing.List[NumericOption] = options
 984        """ The possible options. """
 985
 986    def shuffle(self, rng: random.Random) -> None:
 987        rng.shuffle(self.options)
 988
 989    def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
 990        """
 991        Get the text document for the first option.
 992        If there is no option, return an empty document.
 993        """
 994
 995        if (len(self.options) == 0):
 996            return quizcomp.parser.document.ParsedDocument()
 997
 998        return self.options[0].to_text().text
 999
1000    def to_pod(self,
1001            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
1002            ) -> edq.util.serial.PODType:
1003        return [option.to_pod(context) for option in self.options]
1004
1005    @classmethod
1006    def from_pod(cls: typing.Type['NumericAnswers'],
1007            data: edq.util.serial.PODType,
1008            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
1009            ) -> 'NumericAnswers':
1010        if (context is None):
1011            context = edq.util.serial.SerializationContext()
1012
1013        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
1014        raw_options = typing.cast(typing.List[edq.util.serial.PODType], data)
1015
1016        if (len(raw_options) == 0):
1017            raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context)
1018
1019        options = []
1020        for (i, raw_option) in enumerate(raw_options):
1021            label = f"Option at index {i}"
1022
1023            option = NumericOption.from_pod_with_error(raw_option, label, context)
1024            options.append(option)
1025
1026        return NumericAnswers(options)
class NumericAnswerType(enum.Enum):
16class NumericAnswerType(enum.Enum):
17    """ The types of numeric answers supported by the Quiz Composer. """
18
19    EXACT = 'exact'
20    RANGE = 'range'
21    PRECISION = 'precision'

The types of numeric answers supported by the Quiz Composer.

EXACT = <NumericAnswerType.EXACT: 'exact'>
RANGE = <NumericAnswerType.RANGE: 'range'>
PRECISION = <NumericAnswerType.PRECISION: 'precision'>
class TextOption(edq.util.serial.PODConverter):
 23class TextOption(edq.util.serial.PODConverter):
 24    """
 25    One possible text answer to a question.
 26    """
 27
 28    serialization_omit_none = True
 29    serialization_omit_empty = True
 30
 31    def __init__(self,
 32            text: quizcomp.parser.document.ParsedDocument,
 33            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
 34            **kwargs: typing.Any) -> None:
 35        self.text: quizcomp.parser.document.ParsedDocument = text
 36        """ The text/label for this choice. """
 37
 38        if ((feedback is not None) and feedback.is_empty()):
 39            feedback = None
 40
 41        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
 42        """ Feedback specific to this choice. """
 43
 44    def to_pod(self,
 45            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 46            ) -> edq.util.serial.PODType:
 47        if (self.feedback is None):
 48            return self.text.to_pod(context)
 49
 50        return {
 51            'text': self.text.to_pod(context),
 52            'feedback': self.feedback.to_pod(context),
 53        }
 54
 55    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 56        """ Collect all documents in this object. """
 57
 58        documents = [self.text]
 59
 60        if (self.feedback is not None):
 61            documents += self.feedback.collect_documents()
 62
 63        return documents
 64
 65    @classmethod
 66    def from_pod(cls: typing.Type['TextOption'],
 67            data: edq.util.serial.PODType,
 68            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 69            ) -> 'TextOption':
 70        if (context is None):
 71            context = edq.util.serial.SerializationContext()
 72
 73        label = context.extra.get('label', '')
 74
 75        if (isinstance(data, str)):
 76            parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context)
 77            return TextOption(parsed_text, None)
 78
 79        if (not isinstance(data, dict)):
 80            raise quizcomp.model.errors.QuestionValidationError(
 81                f"{label} has text in an unknown format (not a string or dict): '{data}' (type: {type(data)}.",
 82                context = context)
 83
 84        raw_text = data.get('text', None)
 85        if (raw_text is None):
 86            raise quizcomp.model.errors.QuestionValidationError(
 87                    f"{label} has no 'text' field set.",
 88                    context = context)
 89
 90        parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(str(raw_text), context)
 91        feedback = quizcomp.model.feedback.Feedback.from_raw_data(data.get('feedback', None), context)
 92
 93        return TextOption(parsed_text, feedback)
 94
 95    @classmethod
 96    def from_pod_with_error(cls: typing.Type['TextOption'],
 97            data: edq.util.serial.PODType,
 98            label: str,
 99            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
100            ) -> 'TextOption':
101        """ Wrap from_pod() with some error information. """
102
103        if (context is None):
104            context = edq.util.serial.SerializationContext()
105        else:
106            context = context.copy()
107
108        context.extra['label'] = label
109
110        return cls.from_pod(data, context)

One possible text answer to a question.

TextOption( text: quizcomp.parser.document.ParsedDocument, feedback: Optional[quizcomp.model.feedback.Feedback] = None, **kwargs: Any)
31    def __init__(self,
32            text: quizcomp.parser.document.ParsedDocument,
33            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
34            **kwargs: typing.Any) -> None:
35        self.text: quizcomp.parser.document.ParsedDocument = text
36        """ The text/label for this choice. """
37
38        if ((feedback is not None) and feedback.is_empty()):
39            feedback = None
40
41        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
42        """ Feedback specific to this choice. """
serialization_omit_none = True

Do not include None (null) fields in serialization.

serialization_omit_empty = True

Do not include empty fields in serialization. An empty field meets one of the following conditions:

  • Has a __len__ method which returns 0.
  • Has a _serialization_is_empty method that returns true.

The text/label for this choice.

feedback: Optional[quizcomp.model.feedback.Feedback]

Feedback specific to this choice.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
44    def to_pod(self,
45            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
46            ) -> edq.util.serial.PODType:
47        if (self.feedback is None):
48            return self.text.to_pod(context)
49
50        return {
51            'text': self.text.to_pod(context),
52            'feedback': self.feedback.to_pod(context),
53        }

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
55    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
56        """ Collect all documents in this object. """
57
58        documents = [self.text]
59
60        if (self.feedback is not None):
61            documents += self.feedback.collect_documents()
62
63        return documents

Collect all documents in this object.

@classmethod
def from_pod( cls: Type[TextOption], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> TextOption:
65    @classmethod
66    def from_pod(cls: typing.Type['TextOption'],
67            data: edq.util.serial.PODType,
68            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
69            ) -> 'TextOption':
70        if (context is None):
71            context = edq.util.serial.SerializationContext()
72
73        label = context.extra.get('label', '')
74
75        if (isinstance(data, str)):
76            parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context)
77            return TextOption(parsed_text, None)
78
79        if (not isinstance(data, dict)):
80            raise quizcomp.model.errors.QuestionValidationError(
81                f"{label} has text in an unknown format (not a string or dict): '{data}' (type: {type(data)}.",
82                context = context)
83
84        raw_text = data.get('text', None)
85        if (raw_text is None):
86            raise quizcomp.model.errors.QuestionValidationError(
87                    f"{label} has no 'text' field set.",
88                    context = context)
89
90        parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(str(raw_text), context)
91        feedback = quizcomp.model.feedback.Feedback.from_raw_data(data.get('feedback', None), context)
92
93        return TextOption(parsed_text, feedback)

Create an instance of this class from a POD.

The default implementation will call the class' constructor with one of two things: a splat/unpacking (**) of the incoming data if the data is a dict, otherwise the data itself.

@classmethod
def from_pod_with_error( cls: Type[TextOption], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], label: str, context: Optional[edq.util.common.SerializationContext] = None) -> TextOption:
 95    @classmethod
 96    def from_pod_with_error(cls: typing.Type['TextOption'],
 97            data: edq.util.serial.PODType,
 98            label: str,
 99            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
100            ) -> 'TextOption':
101        """ Wrap from_pod() with some error information. """
102
103        if (context is None):
104            context = edq.util.serial.SerializationContext()
105        else:
106            context = context.copy()
107
108        context.extra['label'] = label
109
110        return cls.from_pod(data, context)

Wrap from_pod() with some error information.

class NumericOption(edq.util.serial.PODConverter, abc.ABC):
112class NumericOption(edq.util.serial.PODConverter, abc.ABC):
113    """
114    One possible numeric answer to a question.
115    """
116
117    serialization_omit_none = True
118    serialization_omit_empty = True
119
120    def __init__(self,
121            type: NumericAnswerType,
122            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
123            **kwargs: typing.Any) -> None:
124        self.type: NumericAnswerType = type
125        """ The type of numeric answer. """
126
127        if ((feedback is not None) and feedback.is_empty()):
128            feedback = None
129
130        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
131        """ Feedback specific to this choice. """
132
133    @abc.abstractmethod
134    def to_text(self) -> TextOption:
135        """ Get a textual representation of this option. """
136
137    @classmethod
138    def from_pod(cls: typing.Type['NumericOption'],
139            data: edq.util.serial.PODType,
140            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
141            ) -> 'NumericOption':
142        if (context is None):
143            context = edq.util.serial.SerializationContext()
144
145        label = context.extra.get('label', '')
146
147        quizcomp.model.errors.check_type(data, dict, label, context = context)
148        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
149
150        raw_answer_type = dict_data.get('type', None)
151        if (not edq.util.enum.has_value(NumericAnswerType, raw_answer_type)):
152            raise quizcomp.model.errors.QuestionValidationError(
153                    f"{label} has an unknown answer type: '{raw_answer_type}'.",
154                    context = context)
155
156        answer_type = NumericAnswerType(raw_answer_type)
157
158        feedback = quizcomp.model.feedback.Feedback.from_raw_data(dict_data.get('feedback', None), context = context)
159
160        if (answer_type == NumericAnswerType.EXACT):
161            value = dict_data.get('value', None)
162            if (value is None):
163                raise quizcomp.model.errors.QuestionValidationError(
164                        f"{label} does not have a required 'value' key.",
165                        context = context)
166
167            if (not isinstance(value, (int, float))):
168                raise quizcomp.model.errors.QuestionValidationError(
169                        f"{label} has a 'value' that is not an int or float, found '{type(value)}'.",
170                        context = context)
171
172            margin = dict_data.get('margin', 0.0)
173            if (not isinstance(margin, (int, float))):
174                raise quizcomp.model.errors.QuestionValidationError(
175                        f"{label} has a 'margin' that is not an int or float, found '{type(margin)}'.",
176                        context = context)
177
178            return NumericOptionExact(value, margin, feedback = feedback)
179        elif (answer_type == NumericAnswerType.RANGE):
180            min = dict_data.get('min', None)
181            if (min is None):
182                raise quizcomp.model.errors.QuestionValidationError(
183                        f"{label} does not have a required 'min' key.",
184                        context = context)
185
186            if (not isinstance(min, (int, float))):
187                raise quizcomp.model.errors.QuestionValidationError(
188                        f"{label} has a 'min' that is not an int or float, found '{type(min)}'.",
189                        context = context)
190
191            max = dict_data.get('max', None)
192            if (max is None):
193                raise quizcomp.model.errors.QuestionValidationError(
194                        f"{label} does not have a required 'max' key.",
195                        context = context)
196
197            if (not isinstance(max, (int, float))):
198                raise quizcomp.model.errors.QuestionValidationError(
199                        f"{label} has a 'max' that is not an int or float, found '{type(max)}'.",
200                        context = context)
201
202            return NumericOptionRange(min, max, feedback = feedback)
203        elif (answer_type == NumericAnswerType.PRECISION):
204            value = dict_data.get('value', None)
205            if (value is None):
206                raise quizcomp.model.errors.QuestionValidationError(
207                        f"{label} does not have a required 'value' key.",
208                        context = context)
209
210            if (not isinstance(value, (int, float))):
211                raise quizcomp.model.errors.QuestionValidationError(
212                        f"{label} has a 'value' that is not an int or float, found '{type(value)}'.",
213                        context = context)
214
215            precision = dict_data.get('precision', None)
216            if (precision is None):
217                raise quizcomp.model.errors.QuestionValidationError(
218                        f"{label} does not have a required 'precision' key.",
219                        context = context)
220
221            if (not isinstance(precision, int)):
222                raise quizcomp.model.errors.QuestionValidationError(
223                        f"{label} has a 'precision' that is not an int, found '{type(precision)}'.",
224                        context = context)
225
226            return NumericOptionPrecision(value, precision, feedback = feedback)
227        else:
228            raise quizcomp.model.errors.QuestionValidationError(
229                    f"{label} has an unknown answer type: '{answer_type}'.",
230                    context = context)
231
232    @classmethod
233    def from_pod_with_error(cls: typing.Type['NumericOption'],
234            data: edq.util.serial.PODType,
235            label: str,
236            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
237            ) -> 'NumericOption':
238        """ Wrap from_pod() with some error information. """
239
240        if (context is None):
241            context = edq.util.serial.SerializationContext()
242        else:
243            context = context.copy()
244
245        context.extra['label'] = label
246
247        return cls.from_pod(data, context)

One possible numeric answer to a question.

serialization_omit_none = True

Do not include None (null) fields in serialization.

serialization_omit_empty = True

Do not include empty fields in serialization. An empty field meets one of the following conditions:

  • Has a __len__ method which returns 0.
  • Has a _serialization_is_empty method that returns true.

The type of numeric answer.

feedback: Optional[quizcomp.model.feedback.Feedback]

Feedback specific to this choice.

@abc.abstractmethod
def to_text(self) -> TextOption:
133    @abc.abstractmethod
134    def to_text(self) -> TextOption:
135        """ Get a textual representation of this option. """

Get a textual representation of this option.

@classmethod
def from_pod( cls: Type[NumericOption], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> NumericOption:
137    @classmethod
138    def from_pod(cls: typing.Type['NumericOption'],
139            data: edq.util.serial.PODType,
140            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
141            ) -> 'NumericOption':
142        if (context is None):
143            context = edq.util.serial.SerializationContext()
144
145        label = context.extra.get('label', '')
146
147        quizcomp.model.errors.check_type(data, dict, label, context = context)
148        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
149
150        raw_answer_type = dict_data.get('type', None)
151        if (not edq.util.enum.has_value(NumericAnswerType, raw_answer_type)):
152            raise quizcomp.model.errors.QuestionValidationError(
153                    f"{label} has an unknown answer type: '{raw_answer_type}'.",
154                    context = context)
155
156        answer_type = NumericAnswerType(raw_answer_type)
157
158        feedback = quizcomp.model.feedback.Feedback.from_raw_data(dict_data.get('feedback', None), context = context)
159
160        if (answer_type == NumericAnswerType.EXACT):
161            value = dict_data.get('value', None)
162            if (value is None):
163                raise quizcomp.model.errors.QuestionValidationError(
164                        f"{label} does not have a required 'value' key.",
165                        context = context)
166
167            if (not isinstance(value, (int, float))):
168                raise quizcomp.model.errors.QuestionValidationError(
169                        f"{label} has a 'value' that is not an int or float, found '{type(value)}'.",
170                        context = context)
171
172            margin = dict_data.get('margin', 0.0)
173            if (not isinstance(margin, (int, float))):
174                raise quizcomp.model.errors.QuestionValidationError(
175                        f"{label} has a 'margin' that is not an int or float, found '{type(margin)}'.",
176                        context = context)
177
178            return NumericOptionExact(value, margin, feedback = feedback)
179        elif (answer_type == NumericAnswerType.RANGE):
180            min = dict_data.get('min', None)
181            if (min is None):
182                raise quizcomp.model.errors.QuestionValidationError(
183                        f"{label} does not have a required 'min' key.",
184                        context = context)
185
186            if (not isinstance(min, (int, float))):
187                raise quizcomp.model.errors.QuestionValidationError(
188                        f"{label} has a 'min' that is not an int or float, found '{type(min)}'.",
189                        context = context)
190
191            max = dict_data.get('max', None)
192            if (max is None):
193                raise quizcomp.model.errors.QuestionValidationError(
194                        f"{label} does not have a required 'max' key.",
195                        context = context)
196
197            if (not isinstance(max, (int, float))):
198                raise quizcomp.model.errors.QuestionValidationError(
199                        f"{label} has a 'max' that is not an int or float, found '{type(max)}'.",
200                        context = context)
201
202            return NumericOptionRange(min, max, feedback = feedback)
203        elif (answer_type == NumericAnswerType.PRECISION):
204            value = dict_data.get('value', None)
205            if (value is None):
206                raise quizcomp.model.errors.QuestionValidationError(
207                        f"{label} does not have a required 'value' key.",
208                        context = context)
209
210            if (not isinstance(value, (int, float))):
211                raise quizcomp.model.errors.QuestionValidationError(
212                        f"{label} has a 'value' that is not an int or float, found '{type(value)}'.",
213                        context = context)
214
215            precision = dict_data.get('precision', None)
216            if (precision is None):
217                raise quizcomp.model.errors.QuestionValidationError(
218                        f"{label} does not have a required 'precision' key.",
219                        context = context)
220
221            if (not isinstance(precision, int)):
222                raise quizcomp.model.errors.QuestionValidationError(
223                        f"{label} has a 'precision' that is not an int, found '{type(precision)}'.",
224                        context = context)
225
226            return NumericOptionPrecision(value, precision, feedback = feedback)
227        else:
228            raise quizcomp.model.errors.QuestionValidationError(
229                    f"{label} has an unknown answer type: '{answer_type}'.",
230                    context = context)

Create an instance of this class from a POD.

The default implementation will call the class' constructor with one of two things: a splat/unpacking (**) of the incoming data if the data is a dict, otherwise the data itself.

@classmethod
def from_pod_with_error( cls: Type[NumericOption], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], label: str, context: Optional[edq.util.common.SerializationContext] = None) -> NumericOption:
232    @classmethod
233    def from_pod_with_error(cls: typing.Type['NumericOption'],
234            data: edq.util.serial.PODType,
235            label: str,
236            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
237            ) -> 'NumericOption':
238        """ Wrap from_pod() with some error information. """
239
240        if (context is None):
241            context = edq.util.serial.SerializationContext()
242        else:
243            context = context.copy()
244
245        context.extra['label'] = label
246
247        return cls.from_pod(data, context)

Wrap from_pod() with some error information.

class NumericOptionExact(NumericOption):
249class NumericOptionExact(NumericOption):
250    """ A numeric option to represent an exact value (within a margin). """
251
252    def __init__(self,
253            value: typing.Union[float, int],
254            margin: typing.Union[float, int] = 0.0,
255            **kwargs: typing.Any) -> None:
256        super().__init__(type = NumericAnswerType.EXACT, **kwargs)
257
258        self.value: typing.Union[float, int] = value
259        """ The value for this answer. """
260
261        self.margin: typing.Union[float, int] = margin
262        """ The allowed margin or error for this answer. """
263
264    def to_text(self) -> TextOption:
265        text = str(self.value)
266        if (not math.isclose(self.margin, 0.0)):
267            text += f" ± {self.margin}"
268
269        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)

A numeric option to represent an exact value (within a margin).

NumericOptionExact( value: Union[float, int], margin: Union[float, int] = 0.0, **kwargs: Any)
252    def __init__(self,
253            value: typing.Union[float, int],
254            margin: typing.Union[float, int] = 0.0,
255            **kwargs: typing.Any) -> None:
256        super().__init__(type = NumericAnswerType.EXACT, **kwargs)
257
258        self.value: typing.Union[float, int] = value
259        """ The value for this answer. """
260
261        self.margin: typing.Union[float, int] = margin
262        """ The allowed margin or error for this answer. """
value: Union[float, int]

The value for this answer.

margin: Union[float, int]

The allowed margin or error for this answer.

def to_text(self) -> TextOption:
264    def to_text(self) -> TextOption:
265        text = str(self.value)
266        if (not math.isclose(self.margin, 0.0)):
267            text += f" ± {self.margin}"
268
269        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)

Get a textual representation of this option.

class NumericOptionRange(NumericOption):
271class NumericOptionRange(NumericOption):
272    """ A numeric option to represent a value within a range. """
273
274    def __init__(self,
275            min: typing.Union[float, int],
276            max: typing.Union[float, int],
277            **kwargs: typing.Any) -> None:
278        super().__init__(type = NumericAnswerType.RANGE, **kwargs)
279
280        self.min: typing.Union[float, int] = min
281        """ The minimum allowed value. """
282
283        self.max: typing.Union[float, int] = max
284        """ The maximum allowed value. """
285
286    def to_text(self) -> TextOption:
287        text = f"[{self.min}, {self.max}]"
288        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)

A numeric option to represent a value within a range.

NumericOptionRange(min: Union[float, int], max: Union[float, int], **kwargs: Any)
274    def __init__(self,
275            min: typing.Union[float, int],
276            max: typing.Union[float, int],
277            **kwargs: typing.Any) -> None:
278        super().__init__(type = NumericAnswerType.RANGE, **kwargs)
279
280        self.min: typing.Union[float, int] = min
281        """ The minimum allowed value. """
282
283        self.max: typing.Union[float, int] = max
284        """ The maximum allowed value. """
min: Union[float, int]

The minimum allowed value.

max: Union[float, int]

The maximum allowed value.

def to_text(self) -> TextOption:
286    def to_text(self) -> TextOption:
287        text = f"[{self.min}, {self.max}]"
288        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)

Get a textual representation of this option.

class NumericOptionPrecision(NumericOption):
290class NumericOptionPrecision(NumericOption):
291    """ A numeric option to represent a value within a specified order of magnitudes. """
292
293    def __init__(self,
294            value: typing.Union[float, int],
295            precision: int,
296            **kwargs: typing.Any) -> None:
297        super().__init__(type = NumericAnswerType.PRECISION, **kwargs)
298
299        self.value: typing.Union[float, int] = value
300        """ The value for this answer. """
301
302        self.precision: int = precision
303        """ The number of order of magnitudes allowed. """
304
305    def to_text(self) -> TextOption:
306        text = str(self.value)
307        if (self.precision != 1):
308            text += f" ({self.precision} decimal places)"
309
310        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)

A numeric option to represent a value within a specified order of magnitudes.

NumericOptionPrecision(value: Union[float, int], precision: int, **kwargs: Any)
293    def __init__(self,
294            value: typing.Union[float, int],
295            precision: int,
296            **kwargs: typing.Any) -> None:
297        super().__init__(type = NumericAnswerType.PRECISION, **kwargs)
298
299        self.value: typing.Union[float, int] = value
300        """ The value for this answer. """
301
302        self.precision: int = precision
303        """ The number of order of magnitudes allowed. """
value: Union[float, int]

The value for this answer.

precision: int

The number of order of magnitudes allowed.

def to_text(self) -> TextOption:
305    def to_text(self) -> TextOption:
306        text = str(self.value)
307        if (self.precision != 1):
308            text += f" ({self.precision} decimal places)"
309
310        return TextOption(quizcomp.parser.document.ParsedDocument.parse_text(text), feedback = self.feedback)

Get a textual representation of this option.

class Choice(TextOption):
312class Choice(TextOption):
313    """
314    One possible choice for an answer.
315    This is for questions with a finite number of choices (e.g., MCQ, MA, TF).
316    """
317
318    serialization_omit_none = True
319
320    def __init__(self,
321            text: quizcomp.parser.document.ParsedDocument,
322            correct: bool,
323            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
324            **kwargs: typing.Any) -> None:
325        super().__init__(text, feedback)
326
327        self.correct: bool = correct
328        """ Whether this choice is a correct answer. """
329
330    def to_pod(self,
331            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
332            ) -> edq.util.serial.PODType:
333        data: typing.Dict[str, edq.util.serial.PODType] = {
334            'text': self.text.to_pod(context),
335            'correct': self.correct,
336        }
337
338        if (self.feedback is not None):
339            data['feedback'] = self.feedback.to_pod(context)
340
341        return data

One possible choice for an answer. This is for questions with a finite number of choices (e.g., MCQ, MA, TF).

Choice( text: quizcomp.parser.document.ParsedDocument, correct: bool, feedback: Optional[quizcomp.model.feedback.Feedback] = None, **kwargs: Any)
320    def __init__(self,
321            text: quizcomp.parser.document.ParsedDocument,
322            correct: bool,
323            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
324            **kwargs: typing.Any) -> None:
325        super().__init__(text, feedback)
326
327        self.correct: bool = correct
328        """ Whether this choice is a correct answer. """
serialization_omit_none = True

Do not include None (null) fields in serialization.

correct: bool

Whether this choice is a correct answer.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
330    def to_pod(self,
331            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
332            ) -> edq.util.serial.PODType:
333        data: typing.Dict[str, edq.util.serial.PODType] = {
334            'text': self.text.to_pod(context),
335            'correct': self.correct,
336        }
337
338        if (self.feedback is not None):
339            data['feedback'] = self.feedback.to_pod(context)
340
341        return data

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

class QuestionAnswers(edq.util.serial.PODConverter):
343class QuestionAnswers(edq.util.serial.PODConverter):
344    """
345    The base type that represents all the listed answers/choices for a question.
346    The exact contents of answers vary depending on the question's type.
347    """
348
349    def shuffle(self, rng: random.Random) -> None:
350        """ Shuffle the choices/options (if applicable). """
351
352    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
353        """ Collect all documents in this object. """
354
355        return []
356
357    @classmethod
358    def from_pod(cls: typing.Type['QuestionAnswers'],
359            data: edq.util.serial.PODType,
360            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
361            ) -> 'QuestionAnswers':
362        """
363        Create an answers object for a specific question type from some serialized data
364        This data will normally come from a JSON file.
365        Because of the differing nature of questions, several different forms of answers may need to be processed
366        (even for the same question type).
367
368        This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
369        """
370
371        if (context is None):
372            context = edq.util.serial.SerializationContext()
373
374        raw_question_type = context.extra.get('question_type', None)
375
376        if (raw_question_type is None):
377            raise quizcomp.model.errors.QuestionValidationError(
378                    "Could not parse question answers because of lack of question type.",
379                    context = context)
380
381        question_type = quizcomp.model.constants.QuestionType(raw_question_type)
382
383        if (question_type == quizcomp.model.constants.QuestionType.ESSAY):
384            return TextAnswers.from_pod(data, context)
385        elif (question_type == quizcomp.model.constants.QuestionType.FIMB):
386            return MultiplePartTextAnswers.from_pod(data, context)
387        elif (question_type == quizcomp.model.constants.QuestionType.FITB):
388            return TextAnswers.from_pod(data, context)
389        elif (question_type == quizcomp.model.constants.QuestionType.MA):
390            context = context.copy()
391            context.extra['min_correct'] = 0
392            return ChoiceAnswers.from_pod(data, context)
393        elif (question_type == quizcomp.model.constants.QuestionType.MATCHING):
394            return MatchingAnswers.from_pod(data, context)
395        elif (question_type == quizcomp.model.constants.QuestionType.MCQ):
396            context = context.copy()
397            context.extra['min_correct'] = 1
398            context.extra['max_correct'] = 1
399            return ChoiceAnswers.from_pod(data, context)
400        elif (question_type == quizcomp.model.constants.QuestionType.MDD):
401            context = context.copy()
402            context.extra['min_correct'] = 1
403            context.extra['max_correct'] = 1
404            return MultiplePartChoiceAnswers.from_pod(data, context)
405        elif (question_type == quizcomp.model.constants.QuestionType.NUMERICAL):
406            return NumericAnswers.from_pod(data, context)
407        elif (question_type == quizcomp.model.constants.QuestionType.SA):
408            return TextAnswers.from_pod(data, context)
409        elif (question_type == quizcomp.model.constants.QuestionType.TEXT_ONLY):
410            return TextAnswers.from_pod(data, context)
411        elif (question_type == quizcomp.model.constants.QuestionType.TF):
412            return TFAnswers.from_pod(data, context)
413        else:
414            raise quizcomp.model.errors.QuestionValidationError(
415                    f"Unknown question type: '{raw_question_type}'.",
416                    context = context)

The base type that represents all the listed answers/choices for a question. The exact contents of answers vary depending on the question's type.

def shuffle(self, rng: random.Random) -> None:
349    def shuffle(self, rng: random.Random) -> None:
350        """ Shuffle the choices/options (if applicable). """

Shuffle the choices/options (if applicable).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
352    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
353        """ Collect all documents in this object. """
354
355        return []

Collect all documents in this object.

@classmethod
def from_pod( cls: Type[QuestionAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> QuestionAnswers:
357    @classmethod
358    def from_pod(cls: typing.Type['QuestionAnswers'],
359            data: edq.util.serial.PODType,
360            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
361            ) -> 'QuestionAnswers':
362        """
363        Create an answers object for a specific question type from some serialized data
364        This data will normally come from a JSON file.
365        Because of the differing nature of questions, several different forms of answers may need to be processed
366        (even for the same question type).
367
368        This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.
369        """
370
371        if (context is None):
372            context = edq.util.serial.SerializationContext()
373
374        raw_question_type = context.extra.get('question_type', None)
375
376        if (raw_question_type is None):
377            raise quizcomp.model.errors.QuestionValidationError(
378                    "Could not parse question answers because of lack of question type.",
379                    context = context)
380
381        question_type = quizcomp.model.constants.QuestionType(raw_question_type)
382
383        if (question_type == quizcomp.model.constants.QuestionType.ESSAY):
384            return TextAnswers.from_pod(data, context)
385        elif (question_type == quizcomp.model.constants.QuestionType.FIMB):
386            return MultiplePartTextAnswers.from_pod(data, context)
387        elif (question_type == quizcomp.model.constants.QuestionType.FITB):
388            return TextAnswers.from_pod(data, context)
389        elif (question_type == quizcomp.model.constants.QuestionType.MA):
390            context = context.copy()
391            context.extra['min_correct'] = 0
392            return ChoiceAnswers.from_pod(data, context)
393        elif (question_type == quizcomp.model.constants.QuestionType.MATCHING):
394            return MatchingAnswers.from_pod(data, context)
395        elif (question_type == quizcomp.model.constants.QuestionType.MCQ):
396            context = context.copy()
397            context.extra['min_correct'] = 1
398            context.extra['max_correct'] = 1
399            return ChoiceAnswers.from_pod(data, context)
400        elif (question_type == quizcomp.model.constants.QuestionType.MDD):
401            context = context.copy()
402            context.extra['min_correct'] = 1
403            context.extra['max_correct'] = 1
404            return MultiplePartChoiceAnswers.from_pod(data, context)
405        elif (question_type == quizcomp.model.constants.QuestionType.NUMERICAL):
406            return NumericAnswers.from_pod(data, context)
407        elif (question_type == quizcomp.model.constants.QuestionType.SA):
408            return TextAnswers.from_pod(data, context)
409        elif (question_type == quizcomp.model.constants.QuestionType.TEXT_ONLY):
410            return TextAnswers.from_pod(data, context)
411        elif (question_type == quizcomp.model.constants.QuestionType.TF):
412            return TFAnswers.from_pod(data, context)
413        else:
414            raise quizcomp.model.errors.QuestionValidationError(
415                    f"Unknown question type: '{raw_question_type}'.",
416                    context = context)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class TextAnswers(QuestionAnswers):
418class TextAnswers(QuestionAnswers):
419    """
420    Answers that include a list of possible text options.
421    Note that the text options are not choices (i.e., they are not presented in a multiple choice fashion),
422    instead they are possible answers.
423    Question types with this type of answers are often graded via text equality or manually
424    (where these answers would serve as a guide/rubric).
425    """
426
427    def __init__(self,
428            options: typing.Union[typing.List[TextOption], None] = None,
429            **kwargs: typing.Any) -> None:
430        super().__init__(**kwargs)
431
432        if (options is None):
433            options = []
434
435        self.options: typing.List[TextOption] = options
436        """ The possible text options. """
437
438    def shuffle(self, rng: random.Random) -> None:
439        rng.shuffle(self.options)
440
441    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
442        documents = []
443
444        for option in self.options:
445            documents += option.collect_documents()
446
447        return documents
448
449    def to_pod(self,
450            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
451            ) -> edq.util.serial.PODType:
452        return [option.to_pod(context) for option in self.options]
453
454    def _serialization_is_empty(self) -> bool:
455        """ A special method for the serialization library to check. """
456
457        return (len(self.options) == 0)
458
459    def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
460        """
461        Get the text document for the first option.
462        If there is no option, return an empty document.
463        """
464
465        if (len(self.options) == 0):
466            return quizcomp.parser.document.ParsedDocument()
467
468        return self.options[0].text
469
470    @classmethod
471    def from_pod(cls: typing.Type['TextAnswers'],
472            data: edq.util.serial.PODType,
473            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
474            ) -> 'TextAnswers':
475        if (data is None):
476            return TextAnswers()
477
478        if (context is None):
479            context = edq.util.serial.SerializationContext()
480
481        if (isinstance(data, str)):
482            parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context)
483            return TextAnswers([TextOption(parsed_text, None)])
484
485        if (isinstance(data, dict)):
486            data = [data]
487
488        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
489        list_data = typing.cast(typing.List[edq.util.serial.PODType], data)
490
491        if (len(list_data) == 0):
492            return TextAnswers()
493
494        options = []
495        for (i, raw_option) in enumerate(list_data):
496            label = f"Choice at index {i}"
497
498            option = TextOption.from_pod_with_error(raw_option, label, context)
499            options.append(option)
500
501        return TextAnswers(options)

Answers that include a list of possible text options. Note that the text options are not choices (i.e., they are not presented in a multiple choice fashion), instead they are possible answers. Question types with this type of answers are often graded via text equality or manually (where these answers would serve as a guide/rubric).

TextAnswers( options: Optional[List[TextOption]] = None, **kwargs: Any)
427    def __init__(self,
428            options: typing.Union[typing.List[TextOption], None] = None,
429            **kwargs: typing.Any) -> None:
430        super().__init__(**kwargs)
431
432        if (options is None):
433            options = []
434
435        self.options: typing.List[TextOption] = options
436        """ The possible text options. """
options: List[TextOption]

The possible text options.

def shuffle(self, rng: random.Random) -> None:
438    def shuffle(self, rng: random.Random) -> None:
439        rng.shuffle(self.options)

Shuffle the choices/options (if applicable).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
441    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
442        documents = []
443
444        for option in self.options:
445            documents += option.collect_documents()
446
447        return documents

Collect all documents in this object.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
449    def to_pod(self,
450            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
451            ) -> edq.util.serial.PODType:
452        return [option.to_pod(context) for option in self.options]

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
459    def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
460        """
461        Get the text document for the first option.
462        If there is no option, return an empty document.
463        """
464
465        if (len(self.options) == 0):
466            return quizcomp.parser.document.ParsedDocument()
467
468        return self.options[0].text

Get the text document for the first option. If there is no option, return an empty document.

@classmethod
def from_pod( cls: Type[TextAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> TextAnswers:
470    @classmethod
471    def from_pod(cls: typing.Type['TextAnswers'],
472            data: edq.util.serial.PODType,
473            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
474            ) -> 'TextAnswers':
475        if (data is None):
476            return TextAnswers()
477
478        if (context is None):
479            context = edq.util.serial.SerializationContext()
480
481        if (isinstance(data, str)):
482            parsed_text = quizcomp.parser.document.ParsedDocument.parse_text(data, context)
483            return TextAnswers([TextOption(parsed_text, None)])
484
485        if (isinstance(data, dict)):
486            data = [data]
487
488        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
489        list_data = typing.cast(typing.List[edq.util.serial.PODType], data)
490
491        if (len(list_data) == 0):
492            return TextAnswers()
493
494        options = []
495        for (i, raw_option) in enumerate(list_data):
496            label = f"Choice at index {i}"
497
498            option = TextOption.from_pod_with_error(raw_option, label, context)
499            options.append(option)
500
501        return TextAnswers(options)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class MultiplePartTextAnswers(QuestionAnswers):
503class MultiplePartTextAnswers(QuestionAnswers):
504    """
505    Answers that have multiple parts, each having their own text-based answers.
506    """
507
508    def __init__(self,
509            parts: typing.Dict[str, TextAnswers],
510            *args: typing.Any,
511            **kwargs: typing.Any) -> None:
512        super().__init__(*args, **kwargs)
513
514        self.parts: typing.Dict[str, TextAnswers] = parts
515        """ The different parts of this question. """
516
517    def shuffle(self, rng: random.Random) -> None:
518        for part in self.parts.values():
519            part.shuffle(rng)
520
521    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
522        documents = []
523
524        for part in self.parts.values():
525            documents += part.collect_documents()
526
527        return documents
528
529    def to_pod(self,
530            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
531            ) -> edq.util.serial.PODType:
532        return {key: value.to_pod(context) for (key, value) in self.parts.items()}
533
534    @classmethod
535    def from_pod(cls: typing.Type['MultiplePartTextAnswers'],
536            data: edq.util.serial.PODType,
537            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
538            ) -> 'MultiplePartTextAnswers':
539        if (context is None):
540            context = edq.util.serial.SerializationContext()
541
542        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
543        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
544
545        parts = {}
546        for (key, raw_options) in dict_data.items():
547            # Try to parse the key, even though we are not storing it right now.
548            quizcomp.parser.document.ParsedDocument.parse_text(key, context)
549
550            parts[key] = TextAnswers.from_pod(raw_options, context)
551
552        return MultiplePartTextAnswers(parts)

Answers that have multiple parts, each having their own text-based answers.

MultiplePartTextAnswers( parts: Dict[str, TextAnswers], *args: Any, **kwargs: Any)
508    def __init__(self,
509            parts: typing.Dict[str, TextAnswers],
510            *args: typing.Any,
511            **kwargs: typing.Any) -> None:
512        super().__init__(*args, **kwargs)
513
514        self.parts: typing.Dict[str, TextAnswers] = parts
515        """ The different parts of this question. """
parts: Dict[str, TextAnswers]

The different parts of this question.

def shuffle(self, rng: random.Random) -> None:
517    def shuffle(self, rng: random.Random) -> None:
518        for part in self.parts.values():
519            part.shuffle(rng)

Shuffle the choices/options (if applicable).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
521    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
522        documents = []
523
524        for part in self.parts.values():
525            documents += part.collect_documents()
526
527        return documents

Collect all documents in this object.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
529    def to_pod(self,
530            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
531            ) -> edq.util.serial.PODType:
532        return {key: value.to_pod(context) for (key, value) in self.parts.items()}

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

@classmethod
def from_pod( cls: Type[MultiplePartTextAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> MultiplePartTextAnswers:
534    @classmethod
535    def from_pod(cls: typing.Type['MultiplePartTextAnswers'],
536            data: edq.util.serial.PODType,
537            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
538            ) -> 'MultiplePartTextAnswers':
539        if (context is None):
540            context = edq.util.serial.SerializationContext()
541
542        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
543        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
544
545        parts = {}
546        for (key, raw_options) in dict_data.items():
547            # Try to parse the key, even though we are not storing it right now.
548            quizcomp.parser.document.ParsedDocument.parse_text(key, context)
549
550            parts[key] = TextAnswers.from_pod(raw_options, context)
551
552        return MultiplePartTextAnswers(parts)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class ChoiceAnswers(QuestionAnswers):
554class ChoiceAnswers(QuestionAnswers):
555    """ Answers that include a finite set of choices. """
556
557    def __init__(self,
558            choices: typing.List[Choice],
559            *args: typing.Any,
560            **kwargs: typing.Any) -> None:
561        super().__init__(*args, **kwargs)
562
563        self.choices: typing.List[Choice] = choices
564        """ The possible choices. """
565
566    def shuffle(self, rng: random.Random) -> None:
567        rng.shuffle(self.choices)
568
569    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
570        documents = []
571
572        for choice in self.choices:
573            documents += choice.collect_documents()
574
575        return documents
576
577    def to_pod(self,
578            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
579            ) -> edq.util.serial.PODType:
580        return [choice.to_pod(context) for choice in self.choices]
581
582    def get_choices_with_markers(self) -> typing.List[typing.Tuple[quizcomp.parser.document.ParsedDocument, Choice]]:
583        """ Get the choices for this answer along with markers for each (e.g., "A", "B", "C"). """
584
585        return [
586            (quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]), choice)
587            for (i, choice)
588            in enumerate(self.choices)
589        ]
590
591    @classmethod
592    def from_pod(cls: typing.Type['ChoiceAnswers'],
593            data: edq.util.serial.PODType,
594            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
595            ) -> 'ChoiceAnswers':
596        if (context is None):
597            context = edq.util.serial.SerializationContext()
598
599        min_correct = context.extra.get('min_correct', 0)
600        max_correct = context.extra.get('max_correct', quizcomp.model.constants.MAX_CHOICES)
601        min_incorrect = context.extra.get('min_incorrect', 0)
602        max_incorrect = context.extra.get('max_incorrect', quizcomp.model.constants.MAX_CHOICES)
603
604        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
605        raw_choices = typing.cast(typing.List[edq.util.serial.PODType], data)
606
607        if (len(raw_choices) == 0):
608            raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context)
609
610        num_correct = 0
611        num_incorrect = 0
612
613        choices = []
614
615        for (i, raw_choice) in enumerate(raw_choices):
616            label = f"Choice at index {i}"
617
618            quizcomp.model.errors.check_type(raw_choice, dict, label, context = context)
619            choice_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], raw_choice)
620
621            raw_correct = choice_data.get('correct', None)
622            if (raw_correct is None):
623                raise quizcomp.model.errors.QuestionValidationError(f"{label} has no 'correct' field set.", context = context)
624
625            correct = edq.util.parse.soft_boolean(raw_correct)
626            if (correct is None):
627                raise quizcomp.model.errors.QuestionValidationError(
628                        f"{label}'s 'correct' field does not contain a boolean: '{raw_correct}'.",
629                        context = context)
630
631            if (correct):
632                num_correct += 1
633            else:
634                num_incorrect += 1
635
636            option = TextOption.from_pod_with_error(choice_data, label, context)
637            choices.append(Choice(option.text, correct, option.feedback))
638
639        if (num_correct < min_correct):
640            raise quizcomp.model.errors.QuestionValidationError(("Did not find enough correct choices."
641                + f" Expected at least {min_correct}, found {num_correct}."),
642                context = context)
643
644        if (num_correct > max_correct):
645            raise quizcomp.model.errors.QuestionValidationError(("Found too many correct choices."
646                + f" Expected at most {max_correct}, found {num_correct}."),
647                context = context)
648
649        if (num_incorrect < min_incorrect):
650            raise quizcomp.model.errors.QuestionValidationError(("Did not find enough incorrect choices."
651                + f" Expected at least {min_incorrect}, found {num_incorrect}."),
652                context = context)
653
654        if (num_incorrect > max_incorrect):
655            raise quizcomp.model.errors.QuestionValidationError(("Found too many incorrect choices."
656                + f" Expected at most {max_incorrect}, found {num_incorrect}."),
657                context = context)
658
659        return ChoiceAnswers(choices)

Answers that include a finite set of choices.

ChoiceAnswers( choices: List[Choice], *args: Any, **kwargs: Any)
557    def __init__(self,
558            choices: typing.List[Choice],
559            *args: typing.Any,
560            **kwargs: typing.Any) -> None:
561        super().__init__(*args, **kwargs)
562
563        self.choices: typing.List[Choice] = choices
564        """ The possible choices. """
choices: List[Choice]

The possible choices.

def shuffle(self, rng: random.Random) -> None:
566    def shuffle(self, rng: random.Random) -> None:
567        rng.shuffle(self.choices)

Shuffle the choices/options (if applicable).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
569    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
570        documents = []
571
572        for choice in self.choices:
573            documents += choice.collect_documents()
574
575        return documents

Collect all documents in this object.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
577    def to_pod(self,
578            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
579            ) -> edq.util.serial.PODType:
580        return [choice.to_pod(context) for choice in self.choices]

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

def get_choices_with_markers( self) -> List[Tuple[quizcomp.parser.document.ParsedDocument, Choice]]:
582    def get_choices_with_markers(self) -> typing.List[typing.Tuple[quizcomp.parser.document.ParsedDocument, Choice]]:
583        """ Get the choices for this answer along with markers for each (e.g., "A", "B", "C"). """
584
585        return [
586            (quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i]), choice)
587            for (i, choice)
588            in enumerate(self.choices)
589        ]

Get the choices for this answer along with markers for each (e.g., "A", "B", "C").

@classmethod
def from_pod( cls: Type[ChoiceAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> ChoiceAnswers:
591    @classmethod
592    def from_pod(cls: typing.Type['ChoiceAnswers'],
593            data: edq.util.serial.PODType,
594            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
595            ) -> 'ChoiceAnswers':
596        if (context is None):
597            context = edq.util.serial.SerializationContext()
598
599        min_correct = context.extra.get('min_correct', 0)
600        max_correct = context.extra.get('max_correct', quizcomp.model.constants.MAX_CHOICES)
601        min_incorrect = context.extra.get('min_incorrect', 0)
602        max_incorrect = context.extra.get('max_incorrect', quizcomp.model.constants.MAX_CHOICES)
603
604        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
605        raw_choices = typing.cast(typing.List[edq.util.serial.PODType], data)
606
607        if (len(raw_choices) == 0):
608            raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context)
609
610        num_correct = 0
611        num_incorrect = 0
612
613        choices = []
614
615        for (i, raw_choice) in enumerate(raw_choices):
616            label = f"Choice at index {i}"
617
618            quizcomp.model.errors.check_type(raw_choice, dict, label, context = context)
619            choice_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], raw_choice)
620
621            raw_correct = choice_data.get('correct', None)
622            if (raw_correct is None):
623                raise quizcomp.model.errors.QuestionValidationError(f"{label} has no 'correct' field set.", context = context)
624
625            correct = edq.util.parse.soft_boolean(raw_correct)
626            if (correct is None):
627                raise quizcomp.model.errors.QuestionValidationError(
628                        f"{label}'s 'correct' field does not contain a boolean: '{raw_correct}'.",
629                        context = context)
630
631            if (correct):
632                num_correct += 1
633            else:
634                num_incorrect += 1
635
636            option = TextOption.from_pod_with_error(choice_data, label, context)
637            choices.append(Choice(option.text, correct, option.feedback))
638
639        if (num_correct < min_correct):
640            raise quizcomp.model.errors.QuestionValidationError(("Did not find enough correct choices."
641                + f" Expected at least {min_correct}, found {num_correct}."),
642                context = context)
643
644        if (num_correct > max_correct):
645            raise quizcomp.model.errors.QuestionValidationError(("Found too many correct choices."
646                + f" Expected at most {max_correct}, found {num_correct}."),
647                context = context)
648
649        if (num_incorrect < min_incorrect):
650            raise quizcomp.model.errors.QuestionValidationError(("Did not find enough incorrect choices."
651                + f" Expected at least {min_incorrect}, found {num_incorrect}."),
652                context = context)
653
654        if (num_incorrect > max_incorrect):
655            raise quizcomp.model.errors.QuestionValidationError(("Found too many incorrect choices."
656                + f" Expected at most {max_incorrect}, found {num_incorrect}."),
657                context = context)
658
659        return ChoiceAnswers(choices)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class TFAnswers(ChoiceAnswers):
661class TFAnswers(ChoiceAnswers):
662    """ Answers that must be true or false. """
663
664    def __init__(self,
665            *args: typing.Any,
666            **kwargs: typing.Any) -> None:
667        super().__init__(*args, **kwargs)
668
669    @classmethod
670    def from_pod(cls: typing.Type['TFAnswers'],
671            data: edq.util.serial.PODType,
672            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
673            ) ->  'TFAnswers':
674        if (context is None):
675            context = edq.util.serial.SerializationContext()
676        else:
677            context = context.copy()
678
679        if (isinstance(data, bool)):
680            choices = [
681                Choice(quizcomp.parser.document.ParsedDocument.parse_text("True", context), data),
682                Choice(quizcomp.parser.document.ParsedDocument.parse_text("False", context), (not data)),
683            ]
684            return TFAnswers(choices)
685
686        context.extra['min_correct'] = 1
687        context.extra['max_correct'] = 1
688        context.extra['min_incorrect'] = 1
689        context.extra['max_incorrect'] = 1
690
691        answers = ChoiceAnswers.from_pod(data, context)
692
693        return TFAnswers(answers.choices)

Answers that must be true or false.

TFAnswers(*args: Any, **kwargs: Any)
664    def __init__(self,
665            *args: typing.Any,
666            **kwargs: typing.Any) -> None:
667        super().__init__(*args, **kwargs)
@classmethod
def from_pod( cls: Type[TFAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> TFAnswers:
669    @classmethod
670    def from_pod(cls: typing.Type['TFAnswers'],
671            data: edq.util.serial.PODType,
672            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
673            ) ->  'TFAnswers':
674        if (context is None):
675            context = edq.util.serial.SerializationContext()
676        else:
677            context = context.copy()
678
679        if (isinstance(data, bool)):
680            choices = [
681                Choice(quizcomp.parser.document.ParsedDocument.parse_text("True", context), data),
682                Choice(quizcomp.parser.document.ParsedDocument.parse_text("False", context), (not data)),
683            ]
684            return TFAnswers(choices)
685
686        context.extra['min_correct'] = 1
687        context.extra['max_correct'] = 1
688        context.extra['min_incorrect'] = 1
689        context.extra['max_incorrect'] = 1
690
691        answers = ChoiceAnswers.from_pod(data, context)
692
693        return TFAnswers(answers.choices)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class MultiplePartChoiceAnswers(QuestionAnswers):
695class MultiplePartChoiceAnswers(QuestionAnswers):
696    """
697    Answers that have multiple parts, each having their own choice-based answers.
698    """
699
700    def __init__(self,
701            parts: typing.Dict[str, ChoiceAnswers],
702            *args: typing.Any,
703            **kwargs: typing.Any) -> None:
704        super().__init__(*args, **kwargs)
705
706        self.parts: typing.Dict[str, ChoiceAnswers] = parts
707        """ The different parts of this question. """
708
709    def shuffle(self, rng: random.Random) -> None:
710        for part in self.parts.values():
711            part.shuffle(rng)
712
713    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
714        documents = []
715
716        for part in self.parts.values():
717            documents += part.collect_documents()
718
719        return documents
720
721    def to_pod(self,
722            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
723            ) -> edq.util.serial.PODType:
724        return {key: value.to_pod(context) for (key, value) in self.parts.items()}
725
726    @classmethod
727    def from_pod(cls: typing.Type['MultiplePartChoiceAnswers'],
728            data: edq.util.serial.PODType,
729            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
730            ) -> 'MultiplePartChoiceAnswers':
731        if (context is None):
732            context = edq.util.serial.SerializationContext()
733
734        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
735        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
736
737        parts = {}
738        for (key, raw_options) in dict_data.items():
739            # Try to parse the key, even though we are not storing it right now.
740            quizcomp.parser.document.ParsedDocument.parse_text(key, context)
741
742            parts[key] = ChoiceAnswers.from_pod(raw_options, context)
743
744        return MultiplePartChoiceAnswers(parts)

Answers that have multiple parts, each having their own choice-based answers.

MultiplePartChoiceAnswers( parts: Dict[str, ChoiceAnswers], *args: Any, **kwargs: Any)
700    def __init__(self,
701            parts: typing.Dict[str, ChoiceAnswers],
702            *args: typing.Any,
703            **kwargs: typing.Any) -> None:
704        super().__init__(*args, **kwargs)
705
706        self.parts: typing.Dict[str, ChoiceAnswers] = parts
707        """ The different parts of this question. """
parts: Dict[str, ChoiceAnswers]

The different parts of this question.

def shuffle(self, rng: random.Random) -> None:
709    def shuffle(self, rng: random.Random) -> None:
710        for part in self.parts.values():
711            part.shuffle(rng)

Shuffle the choices/options (if applicable).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
713    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
714        documents = []
715
716        for part in self.parts.values():
717            documents += part.collect_documents()
718
719        return documents

Collect all documents in this object.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
721    def to_pod(self,
722            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
723            ) -> edq.util.serial.PODType:
724        return {key: value.to_pod(context) for (key, value) in self.parts.items()}

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

@classmethod
def from_pod( cls: Type[MultiplePartChoiceAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> MultiplePartChoiceAnswers:
726    @classmethod
727    def from_pod(cls: typing.Type['MultiplePartChoiceAnswers'],
728            data: edq.util.serial.PODType,
729            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
730            ) -> 'MultiplePartChoiceAnswers':
731        if (context is None):
732            context = edq.util.serial.SerializationContext()
733
734        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
735        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
736
737        parts = {}
738        for (key, raw_options) in dict_data.items():
739            # Try to parse the key, even though we are not storing it right now.
740            quizcomp.parser.document.ParsedDocument.parse_text(key, context)
741
742            parts[key] = ChoiceAnswers.from_pod(raw_options, context)
743
744        return MultiplePartChoiceAnswers(parts)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class MatchingAnswerRow:
746class MatchingAnswerRow:
747    """
748    A single "row" when writing out a matching problem as a table.
749    """
750
751    def __init__(self,
752            left: typing.Union[TextOption, None],
753            right: TextOption,
754            right_marker: quizcomp.parser.document.ParsedDocument,
755            correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None],
756            correct_option: typing.Union[TextOption, None],
757            ) -> None:
758        self.left: typing.Union[TextOption, None] = left
759        """
760        The query part of the match that needs to find its partner.
761        This may be None if there are distractors.
762        """
763
764        self.right: TextOption = right
765        """
766        The target part of the match.
767        Note that this may NOT be the correct partner to `self.left`,
768        it is just the target that should appear on the same row.
769        """
770
771        self.right_marker: quizcomp.parser.document.ParsedDocument = right_marker
772        """
773        The marker that accompanies this target.
774        """
775
776        self.correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None] = correct_marker
777        """
778        The marker for the correct partner to `self.left`.
779        Will be None if `self.left` is None.
780        """
781
782        self.correct_option: typing.Union[TextOption, None] = correct_option
783        """
784        The text for the correct partner to `self.left`.
785        Will be None if `self.left` is None.
786        """

A single "row" when writing out a matching problem as a table.

MatchingAnswerRow( left: Optional[TextOption], right: TextOption, right_marker: quizcomp.parser.document.ParsedDocument, correct_marker: Optional[quizcomp.parser.document.ParsedDocument], correct_option: Optional[TextOption])
751    def __init__(self,
752            left: typing.Union[TextOption, None],
753            right: TextOption,
754            right_marker: quizcomp.parser.document.ParsedDocument,
755            correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None],
756            correct_option: typing.Union[TextOption, None],
757            ) -> None:
758        self.left: typing.Union[TextOption, None] = left
759        """
760        The query part of the match that needs to find its partner.
761        This may be None if there are distractors.
762        """
763
764        self.right: TextOption = right
765        """
766        The target part of the match.
767        Note that this may NOT be the correct partner to `self.left`,
768        it is just the target that should appear on the same row.
769        """
770
771        self.right_marker: quizcomp.parser.document.ParsedDocument = right_marker
772        """
773        The marker that accompanies this target.
774        """
775
776        self.correct_marker: typing.Union[quizcomp.parser.document.ParsedDocument, None] = correct_marker
777        """
778        The marker for the correct partner to `self.left`.
779        Will be None if `self.left` is None.
780        """
781
782        self.correct_option: typing.Union[TextOption, None] = correct_option
783        """
784        The text for the correct partner to `self.left`.
785        Will be None if `self.left` is None.
786        """
left: Optional[TextOption]

The query part of the match that needs to find its partner. This may be None if there are distractors.

right: TextOption

The target part of the match. Note that this may NOT be the correct partner to self.left, it is just the target that should appear on the same row.

The marker that accompanies this target.

correct_marker: Optional[quizcomp.parser.document.ParsedDocument]

The marker for the correct partner to self.left. Will be None if self.left is None.

correct_option: Optional[TextOption]

The text for the correct partner to self.left. Will be None if self.left is None.

class MatchingAnswers(QuestionAnswers):
788class MatchingAnswers(QuestionAnswers):
789    """ Answers for matching-type questions. """
790
791    serialization_omit_empty = True
792    serialization_skip_fields = {
793        '_shuffle_seed',
794    }
795
796    def __init__(self,
797            pairs: typing.List[typing.Tuple[TextOption, TextOption]],
798            distractors: typing.Union[typing.List[TextOption], None] = None,
799            **kwargs: typing.Any) -> None:
800        super().__init__(**kwargs)
801
802        self.pairs: typing.List[typing.Tuple[TextOption, TextOption]] = pairs
803        """ The matching pairs of items. """
804
805        if (distractors is None):
806            distractors = []
807
808        self.distractors: typing.List[TextOption] = distractors
809        """ Extra options to serve as a distraction. """
810
811        self._shuffle_seed: typing.Union[int, None] = None
812        """
813        A seed to use when shuffling the left and right sides.
814
815        This will be set in shuffle().
816        A None value indicates that no shuffling will occur.
817        """
818
819    def shuffle(self, rng: random.Random) -> None:
820        rng.shuffle(self.pairs)
821        rng.shuffle(self.distractors)
822        self._shuffle_seed = rng.randint(0, 2**64)
823
824    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
825        documents = []
826
827        for (left, right) in self.pairs:
828            documents += left.collect_documents()
829            documents += right.collect_documents()
830
831        for distractor in self.distractors:
832            documents += distractor.collect_documents()
833
834        return documents
835
836    def to_pod(self,
837            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
838            ) -> edq.util.serial.PODType:
839        return {
840            'matches': [[left.to_pod(context), right.to_pod(context)] for (left, right) in self.pairs],
841            'distractors': [value.to_pod(context) for value in self.distractors],
842        }
843
844    def get_tabular_options(self) -> typing.List[MatchingAnswerRow]:
845        """
846        Get all the matching options laid out in a table.
847
848        If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table.
849        """
850
851        lefts = []
852        rights = []
853
854        for (pair_left, pair_right) in self.pairs:
855            lefts.append(pair_left)
856            rights.append(pair_right)
857
858        for distractor in self.distractors:
859            rights.append(distractor)
860
861        # The ordered indexes to use in the options table.
862        # This may be shuffled.
863        left_indexes = list(range(len(lefts)))
864        right_indexes = list(range(len(rights)))
865
866        if (self._shuffle_seed is not None):
867            rng = random.Random(self._shuffle_seed)
868            rng.shuffle(left_indexes)
869            rng.shuffle(right_indexes)
870
871        options = []
872        for (i, right_index) in enumerate(right_indexes):
873            right = rights[right_index]
874            right_marker = quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i])
875
876            left: typing.Union[TextOption, None] = None
877            left_marker = None
878            correct_answer = None
879            if (i < len(left_indexes)):
880                left_index = left_indexes[i]
881
882                # Find the correct marker index for this left by looking up the matching index in the right indexes.
883                matching_right_marker_index = right_indexes.index(left_index)
884
885                left = lefts[left_index]
886                left_marker = quizcomp.parser.document.ParsedDocument.parse_text(
887                        quizcomp.model.constants.DEFAULT_CHOICES[matching_right_marker_index])
888                correct_answer = rights[left_index]
889
890            options.append(MatchingAnswerRow(left, right, right_marker, left_marker, correct_answer))
891
892        return options
893
894    @classmethod
895    def from_pod(cls: typing.Type['MatchingAnswers'],
896            data: edq.util.serial.PODType,
897            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
898            ) -> 'MatchingAnswers':
899        if (context is None):
900            context = edq.util.serial.SerializationContext()
901
902        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
903        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
904
905        raw_matches = dict_data.get('matches', None)
906        if (raw_matches is None):
907            raise quizcomp.model.errors.QuestionValidationError(
908                    "The 'matches' key was not provided for a matching-type question.",
909                    context = context)
910
911        quizcomp.model.errors.check_type(raw_matches, list, "'matches'", context = context)
912        matches = typing.cast(typing.List[edq.util.serial.PODType], raw_matches)
913
914        if (len(matches) == 0):
915            raise quizcomp.model.errors.QuestionValidationError(
916                    "At least one matching pair must be specified for matching questions.",
917                    context = context)
918
919        pairs = []
920        for (i, raw_match) in enumerate(matches):
921            label = f"Match pair at index {i}"
922
923            if (isinstance(raw_match, list)):
924                if (len(raw_match) != 2):
925                    raise quizcomp.model.errors.QuestionValidationError(
926                        f"{label} has an unexpected size. Expecting two items (left and right) found {len(raw_match)}.",
927                        context = context)
928
929                left_option = TextOption.from_pod_with_error(raw_match[0], label + ' (left)', context)
930                right_option = TextOption.from_pod_with_error(raw_match[1], label + ' (right)', context)
931
932                pairs.append((left_option, right_option))
933            elif (isinstance(raw_match, dict)):
934                if ('left' not in raw_match):
935                    raise quizcomp.model.errors.QuestionValidationError(
936                        f"{label} does not have a 'left' key.",
937                        context = context)
938
939                if ('right' not in raw_match):
940                    raise quizcomp.model.errors.QuestionValidationError(
941                        f"{label} does not have a 'right' key.",
942                        context = context)
943
944                left_option = TextOption.from_pod_with_error(raw_match['left'], label + ' (left)', context)
945                right_option = TextOption.from_pod_with_error(raw_match['right'], label + ' (right)', context)
946
947                pairs.append((left_option, right_option))
948            else:
949                raise quizcomp.model.errors.QuestionValidationError(
950                    f"{label} has an unknown format (not a list or dict): '{raw_match}' (type: {type(raw_match)}.",
951                    context = context)
952
953        raw_distractors = dict_data.get('distractors', None)
954        if (raw_distractors is None):
955            raw_distractors = []
956
957        quizcomp.model.errors.check_type(raw_distractors, list, "'distractors'", context = context)
958        distractors = typing.cast(typing.List[edq.util.serial.PODType], raw_distractors)
959
960        claen_distractors = []
961        for (i, raw_distractor) in enumerate(distractors):
962            label = f"Match distractor at index {i}"
963
964            option = TextOption.from_pod_with_error(raw_distractor, label, context)
965            claen_distractors.append(option)
966
967        if ((len(pairs) + len(claen_distractors)) > quizcomp.model.constants.MAX_CHOICES):
968            raise quizcomp.model.errors.QuestionValidationError(
969                (f"Matching question has too many options. Found {(len(pairs) + len(claen_distractors))},"
970                f" while the max is {quizcomp.model.constants.MAX_CHOICES}."),
971                context = context)
972
973        return MatchingAnswers(pairs, claen_distractors)

Answers for matching-type questions.

MatchingAnswers( pairs: List[Tuple[TextOption, TextOption]], distractors: Optional[List[TextOption]] = None, **kwargs: Any)
796    def __init__(self,
797            pairs: typing.List[typing.Tuple[TextOption, TextOption]],
798            distractors: typing.Union[typing.List[TextOption], None] = None,
799            **kwargs: typing.Any) -> None:
800        super().__init__(**kwargs)
801
802        self.pairs: typing.List[typing.Tuple[TextOption, TextOption]] = pairs
803        """ The matching pairs of items. """
804
805        if (distractors is None):
806            distractors = []
807
808        self.distractors: typing.List[TextOption] = distractors
809        """ Extra options to serve as a distraction. """
810
811        self._shuffle_seed: typing.Union[int, None] = None
812        """
813        A seed to use when shuffling the left and right sides.
814
815        This will be set in shuffle().
816        A None value indicates that no shuffling will occur.
817        """
serialization_omit_empty = True

Do not include empty fields in serialization. An empty field meets one of the following conditions:

  • Has a __len__ method which returns 0.
  • Has a _serialization_is_empty method that returns true.
serialization_skip_fields = {'_shuffle_seed'}

A list of field names to skip.

pairs: List[Tuple[TextOption, TextOption]]

The matching pairs of items.

distractors: List[TextOption]

Extra options to serve as a distraction.

def shuffle(self, rng: random.Random) -> None:
819    def shuffle(self, rng: random.Random) -> None:
820        rng.shuffle(self.pairs)
821        rng.shuffle(self.distractors)
822        self._shuffle_seed = rng.randint(0, 2**64)

Shuffle the choices/options (if applicable).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
824    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
825        documents = []
826
827        for (left, right) in self.pairs:
828            documents += left.collect_documents()
829            documents += right.collect_documents()
830
831        for distractor in self.distractors:
832            documents += distractor.collect_documents()
833
834        return documents

Collect all documents in this object.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
836    def to_pod(self,
837            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
838            ) -> edq.util.serial.PODType:
839        return {
840            'matches': [[left.to_pod(context), right.to_pod(context)] for (left, right) in self.pairs],
841            'distractors': [value.to_pod(context) for value in self.distractors],
842        }

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

def get_tabular_options(self) -> List[MatchingAnswerRow]:
844    def get_tabular_options(self) -> typing.List[MatchingAnswerRow]:
845        """
846        Get all the matching options laid out in a table.
847
848        If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table.
849        """
850
851        lefts = []
852        rights = []
853
854        for (pair_left, pair_right) in self.pairs:
855            lefts.append(pair_left)
856            rights.append(pair_right)
857
858        for distractor in self.distractors:
859            rights.append(distractor)
860
861        # The ordered indexes to use in the options table.
862        # This may be shuffled.
863        left_indexes = list(range(len(lefts)))
864        right_indexes = list(range(len(rights)))
865
866        if (self._shuffle_seed is not None):
867            rng = random.Random(self._shuffle_seed)
868            rng.shuffle(left_indexes)
869            rng.shuffle(right_indexes)
870
871        options = []
872        for (i, right_index) in enumerate(right_indexes):
873            right = rights[right_index]
874            right_marker = quizcomp.parser.document.ParsedDocument.parse_text(quizcomp.model.constants.DEFAULT_CHOICES[i])
875
876            left: typing.Union[TextOption, None] = None
877            left_marker = None
878            correct_answer = None
879            if (i < len(left_indexes)):
880                left_index = left_indexes[i]
881
882                # Find the correct marker index for this left by looking up the matching index in the right indexes.
883                matching_right_marker_index = right_indexes.index(left_index)
884
885                left = lefts[left_index]
886                left_marker = quizcomp.parser.document.ParsedDocument.parse_text(
887                        quizcomp.model.constants.DEFAULT_CHOICES[matching_right_marker_index])
888                correct_answer = rights[left_index]
889
890            options.append(MatchingAnswerRow(left, right, right_marker, left_marker, correct_answer))
891
892        return options

Get all the matching options laid out in a table.

If shuffle() was called on this object, then the left and right options will he shuffled before being put into the table.

@classmethod
def from_pod( cls: Type[MatchingAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> MatchingAnswers:
894    @classmethod
895    def from_pod(cls: typing.Type['MatchingAnswers'],
896            data: edq.util.serial.PODType,
897            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
898            ) -> 'MatchingAnswers':
899        if (context is None):
900            context = edq.util.serial.SerializationContext()
901
902        quizcomp.model.errors.check_type(data, dict, "'answers'", context = context)
903        dict_data = typing.cast(typing.Dict[str, edq.util.serial.PODType], data)
904
905        raw_matches = dict_data.get('matches', None)
906        if (raw_matches is None):
907            raise quizcomp.model.errors.QuestionValidationError(
908                    "The 'matches' key was not provided for a matching-type question.",
909                    context = context)
910
911        quizcomp.model.errors.check_type(raw_matches, list, "'matches'", context = context)
912        matches = typing.cast(typing.List[edq.util.serial.PODType], raw_matches)
913
914        if (len(matches) == 0):
915            raise quizcomp.model.errors.QuestionValidationError(
916                    "At least one matching pair must be specified for matching questions.",
917                    context = context)
918
919        pairs = []
920        for (i, raw_match) in enumerate(matches):
921            label = f"Match pair at index {i}"
922
923            if (isinstance(raw_match, list)):
924                if (len(raw_match) != 2):
925                    raise quizcomp.model.errors.QuestionValidationError(
926                        f"{label} has an unexpected size. Expecting two items (left and right) found {len(raw_match)}.",
927                        context = context)
928
929                left_option = TextOption.from_pod_with_error(raw_match[0], label + ' (left)', context)
930                right_option = TextOption.from_pod_with_error(raw_match[1], label + ' (right)', context)
931
932                pairs.append((left_option, right_option))
933            elif (isinstance(raw_match, dict)):
934                if ('left' not in raw_match):
935                    raise quizcomp.model.errors.QuestionValidationError(
936                        f"{label} does not have a 'left' key.",
937                        context = context)
938
939                if ('right' not in raw_match):
940                    raise quizcomp.model.errors.QuestionValidationError(
941                        f"{label} does not have a 'right' key.",
942                        context = context)
943
944                left_option = TextOption.from_pod_with_error(raw_match['left'], label + ' (left)', context)
945                right_option = TextOption.from_pod_with_error(raw_match['right'], label + ' (right)', context)
946
947                pairs.append((left_option, right_option))
948            else:
949                raise quizcomp.model.errors.QuestionValidationError(
950                    f"{label} has an unknown format (not a list or dict): '{raw_match}' (type: {type(raw_match)}.",
951                    context = context)
952
953        raw_distractors = dict_data.get('distractors', None)
954        if (raw_distractors is None):
955            raw_distractors = []
956
957        quizcomp.model.errors.check_type(raw_distractors, list, "'distractors'", context = context)
958        distractors = typing.cast(typing.List[edq.util.serial.PODType], raw_distractors)
959
960        claen_distractors = []
961        for (i, raw_distractor) in enumerate(distractors):
962            label = f"Match distractor at index {i}"
963
964            option = TextOption.from_pod_with_error(raw_distractor, label, context)
965            claen_distractors.append(option)
966
967        if ((len(pairs) + len(claen_distractors)) > quizcomp.model.constants.MAX_CHOICES):
968            raise quizcomp.model.errors.QuestionValidationError(
969                (f"Matching question has too many options. Found {(len(pairs) + len(claen_distractors))},"
970                f" while the max is {quizcomp.model.constants.MAX_CHOICES}."),
971                context = context)
972
973        return MatchingAnswers(pairs, claen_distractors)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.

class NumericAnswers(QuestionAnswers):
 975class NumericAnswers(QuestionAnswers):
 976    """ Answers that include a finite set of numeric options. """
 977
 978    def __init__(self,
 979            options: typing.List[NumericOption],
 980            *args: typing.Any,
 981            **kwargs: typing.Any) -> None:
 982        super().__init__(*args, **kwargs)
 983
 984        self.options: typing.List[NumericOption] = options
 985        """ The possible options. """
 986
 987    def shuffle(self, rng: random.Random) -> None:
 988        rng.shuffle(self.options)
 989
 990    def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
 991        """
 992        Get the text document for the first option.
 993        If there is no option, return an empty document.
 994        """
 995
 996        if (len(self.options) == 0):
 997            return quizcomp.parser.document.ParsedDocument()
 998
 999        return self.options[0].to_text().text
1000
1001    def to_pod(self,
1002            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
1003            ) -> edq.util.serial.PODType:
1004        return [option.to_pod(context) for option in self.options]
1005
1006    @classmethod
1007    def from_pod(cls: typing.Type['NumericAnswers'],
1008            data: edq.util.serial.PODType,
1009            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
1010            ) -> 'NumericAnswers':
1011        if (context is None):
1012            context = edq.util.serial.SerializationContext()
1013
1014        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
1015        raw_options = typing.cast(typing.List[edq.util.serial.PODType], data)
1016
1017        if (len(raw_options) == 0):
1018            raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context)
1019
1020        options = []
1021        for (i, raw_option) in enumerate(raw_options):
1022            label = f"Option at index {i}"
1023
1024            option = NumericOption.from_pod_with_error(raw_option, label, context)
1025            options.append(option)
1026
1027        return NumericAnswers(options)

Answers that include a finite set of numeric options.

NumericAnswers( options: List[NumericOption], *args: Any, **kwargs: Any)
978    def __init__(self,
979            options: typing.List[NumericOption],
980            *args: typing.Any,
981            **kwargs: typing.Any) -> None:
982        super().__init__(*args, **kwargs)
983
984        self.options: typing.List[NumericOption] = options
985        """ The possible options. """
options: List[NumericOption]

The possible options.

def shuffle(self, rng: random.Random) -> None:
987    def shuffle(self, rng: random.Random) -> None:
988        rng.shuffle(self.options)

Shuffle the choices/options (if applicable).

def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
990    def get_first_option_text(self) -> quizcomp.parser.document.ParsedDocument:
991        """
992        Get the text document for the first option.
993        If there is no option, return an empty document.
994        """
995
996        if (len(self.options) == 0):
997            return quizcomp.parser.document.ParsedDocument()
998
999        return self.options[0].to_text().text

Get the text document for the first option. If there is no option, return an empty document.

def to_pod( self, context: Optional[edq.util.common.SerializationContext] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
1001    def to_pod(self,
1002            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
1003            ) -> edq.util.serial.PODType:
1004        return [option.to_pod(context) for option in self.options]

Get a POD representation of this object.

The default implementation will convert to a dict (similar to a DictSerializer).

@classmethod
def from_pod( cls: Type[NumericAnswers], data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> NumericAnswers:
1006    @classmethod
1007    def from_pod(cls: typing.Type['NumericAnswers'],
1008            data: edq.util.serial.PODType,
1009            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
1010            ) -> 'NumericAnswers':
1011        if (context is None):
1012            context = edq.util.serial.SerializationContext()
1013
1014        quizcomp.model.errors.check_type(data, list, "'answers'", context = context)
1015        raw_options = typing.cast(typing.List[edq.util.serial.PODType], data)
1016
1017        if (len(raw_options) == 0):
1018            raise quizcomp.model.errors.QuestionValidationError("No answers provided, at least one answer required.", context = context)
1019
1020        options = []
1021        for (i, raw_option) in enumerate(raw_options):
1022            label = f"Option at index {i}"
1023
1024            option = NumericOption.from_pod_with_error(raw_option, label, context)
1025            options.append(option)
1026
1027        return NumericAnswers(options)

Create an answers object for a specific question type from some serialized data This data will normally come from a JSON file. Because of the differing nature of questions, several different forms of answers may need to be processed (even for the same question type).

This function will not return a generic QuestionAnswers, but a subclass of QuestionAnswers.