quizcomp.model.question

  1import os
  2import random
  3import typing
  4
  5import edq.util.enum
  6import edq.util.serial
  7
  8import quizcomp.model.answer
  9import quizcomp.model.base
 10import quizcomp.model.config
 11import quizcomp.model.constants
 12import quizcomp.model.errors
 13import quizcomp.model.feedback
 14import quizcomp.parser.document
 15
 16DEFAULT_PROMPT_FILENAME: str = 'prompt.md'
 17DEFAULT_QUESTION_FILENAME: str = 'question.json'
 18
 19EMPTY_ANSWER_QUESTION_TYPES: typing.Set[quizcomp.model.constants.QuestionType] = {
 20    quizcomp.model.constants.QuestionType.ESSAY,
 21    quizcomp.model.constants.QuestionType.SA,
 22    quizcomp.model.constants.QuestionType.TEXT_ONLY,
 23}
 24""" Question types that do not have to have an answer. """
 25
 26PLACEHOLDER_QUESTION_TYPES: typing.Set[quizcomp.model.constants.QuestionType] = {
 27    quizcomp.model.constants.QuestionType.FIMB,
 28    quizcomp.model.constants.QuestionType.MDD,
 29}
 30""" Question types that have placeholders. """
 31
 32class Question(quizcomp.model.base.CoreType):
 33    """ A class that represents a question and all answers/feedback for the question. """
 34
 35    def __init__(self,
 36            question_type: quizcomp.model.constants.QuestionType,
 37            prompt: typing.Union[quizcomp.parser.document.ParsedDocument, str],
 38            answers: typing.Union[quizcomp.model.answer.QuestionAnswers, None] = None,
 39            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
 40            **kwargs: typing.Any) -> None:
 41        super().__init__(**kwargs)
 42
 43        self.question_type: quizcomp.model.constants.QuestionType = question_type
 44        """ The type of this question. """
 45
 46        if (isinstance(prompt, str)):
 47            prompt = quizcomp.parser.document.ParsedDocument.parse_text(prompt)
 48
 49        self.prompt: quizcomp.parser.document.ParsedDocument = prompt
 50        """ The parsed prompt of this question. """
 51
 52        if (answers is None):
 53            answers = quizcomp.model.answer.TextAnswers()
 54
 55        self.answers: quizcomp.model.answer.QuestionAnswers = answers
 56        """ The answers for this question. """
 57
 58        if ((feedback is not None) and feedback.is_empty()):
 59            feedback = None
 60
 61        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
 62        """ Object-level feedback. """
 63
 64    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 65        documents = [self.prompt]
 66        documents += self.answers.collect_documents()
 67
 68        if (self.feedback is not None):
 69            documents += self.feedback.collect_documents()
 70
 71        return documents
 72
 73    @classmethod
 74    def from_pod(cls,
 75            data: edq.util.serial.PODType,
 76            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 77            ) -> 'Question':
 78        if (context is None):
 79            context = edq.util.serial.SerializationContext()
 80
 81        if (isinstance(data, dict)):
 82            return super().from_pod(data, context)
 83
 84        if (not isinstance(data, str)):
 85            raise quizcomp.model.errors.QuizValidationError(f"Cannot createquestion object from '{type(data)}' type, need dict or str (path).")
 86
 87        # If a question is being loaded from a string, it is probably a path.
 88        path = str(data)
 89        if (not os.path.isabs(path)):
 90            path = os.path.join(context.base_dir, path)
 91
 92        path = os.path.abspath(path)
 93
 94        return cls.from_path(path, context)
 95
 96    @classmethod
 97    def prep_init_data(cls,
 98            data: typing.Dict[str, typing.Any],
 99            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
100            ) -> typing.Dict[str, typing.Any]:
101        if (context is None):
102            context = edq.util.serial.SerializationContext()
103
104        raw_question_type = data.get('question_type', None)
105        if (raw_question_type is None):
106            raise quizcomp.model.errors.QuestionValidationError("Question data does not include 'question_type'.", context = context)
107
108        question_type = quizcomp.model.constants.QuestionType(raw_question_type)
109        context.extra['question_type'] = question_type
110
111        raw_feedback = data.pop('feedback', None)
112
113        data = super().prep_init_data(data, context)
114
115        data['prompt'] = cls._collect_prompt(data.get('prompt', None), data.get('prompt_path', None), context)
116        data['feedback'] = quizcomp.model.feedback.Feedback.from_raw_data(raw_feedback, context)
117
118        cls._validate_init_data(data, context)
119
120        return data
121
122    @classmethod
123    def _validate_init_data(cls,
124            data: typing.Dict[str, typing.Any],
125            context: edq.util.serial.SerializationContext,
126            ) -> None:
127        """ Validate the data to be sent to __init__() right before construction. """
128
129        question_type = data.get('question_type', None)
130        if (not edq.util.enum.has_value(quizcomp.model.constants.QuestionType, question_type)):
131            raise quizcomp.model.errors.QuestionValidationError(f"Unknown question type: '{question_type}'.", context = context)
132
133        answers = data.get('answers', None)
134        if (answers is None):
135            if (question_type not in EMPTY_ANSWER_QUESTION_TYPES):
136                raise quizcomp.model.errors.QuestionValidationError('No answers to question provided.', context = context)
137
138            data['answers'] = quizcomp.model.answer.TextAnswers()
139
140        prompt_placeholders = data['prompt'].collect_placeholders()
141        if (question_type in PLACEHOLDER_QUESTION_TYPES):
142            answers_placeholders = set(data['answers'].parts.keys())
143
144            if (answers_placeholders != prompt_placeholders):
145                output_answers_placeholders = list(sorted(answers_placeholders))
146                output_prompt_placeholders = list(sorted(prompt_placeholders))
147
148                raise quizcomp.model.errors.QuestionValidationError(
149                        (f"Mismatch between the placeholders found in the question prompt ({output_prompt_placeholders})"
150                            + f" and answers config ({output_answers_placeholders})."),
151                        context = context)
152        elif (len(prompt_placeholders) != 0):
153            raise quizcomp.model.errors.QuestionValidationError(
154                    f"Found placeholders in the prompt for questions that do not use placeholders: '{question_type}'.",
155                    context = context)
156
157    @classmethod
158    def _collect_prompt(cls,
159            text: typing.Union[quizcomp.parser.document.ParsedDocument, str, None],
160            path: typing.Union[str, None],
161            context: edq.util.serial.SerializationContext,
162            ) -> quizcomp.parser.document.ParsedDocument:
163        """
164        Collect the prompt from one of several possible locations.
165
166        The prompt is allowed to appear (in order of priority):
167        1) in the `prompt` field (sent to this function as the `text` argument),
168        2) pointed to by the `prompt_path` field (send to this function as the `path` argument),
169        3) or be in `<base dir>/DEFAULT_PROMPT_FILENAME`.
170
171        Will raise an exception on an empty prompt.
172        Null, empty, and only white space all count as empty.
173        """
174
175        if (text is None):
176            text = ''
177
178        if (isinstance(text, quizcomp.parser.document.ParsedDocument)):
179            return text
180
181        text = text.strip()
182        if (len(text) != 0):
183            return quizcomp.parser.document.ParsedDocument.parse_text(text, context)
184
185        if (path is not None):
186            return quizcomp.parser.document.ParsedDocument.parse_file(path, context)
187
188        path = os.path.abspath(os.path.join(context.base_dir, DEFAULT_PROMPT_FILENAME))
189        if (not os.path.isfile(path)):
190            raise quizcomp.model.errors.QuestionValidationError("Could not find any non-empty prompt.", context = context)
191
192        return quizcomp.parser.document.ParsedDocument.parse_file(path, context)
193
194    def shuffle(self, rng: random.Random) -> None:
195        """
196        Shuffle the answers for this question.
197        This method will do nothing if question shuffling is not allowed by the config settings.
198        """
199
200        if (self.get_config(quizcomp.model.config.OPTION_SHUFFLE_ANSWERS) is not True):
201            return
202
203        self.answers.shuffle(rng)
204
205    def to_dir(self,
206            base_dir: str,
207            fetch_images: bool = True,
208            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
209            **kwargs: typing.Any) -> None:
210        self.base_dir = os.path.abspath(base_dir)
211        edq.util.dirent.mkdir(self.base_dir)
212
213        if (fetch_images):
214            self.fetch_and_update_images()
215
216        output_data = self.to_dict(context = context)
217
218        # Move the prompt to a different file.
219        output_data.pop('prompt', None)
220        if (not self.prompt.is_empty()):
221            edq.util.dirent.write_file(os.path.join(self.base_dir, DEFAULT_PROMPT_FILENAME), self.prompt.to_md())
222
223        edq.util.json.dump_path(output_data, os.path.join(self.base_dir, DEFAULT_QUESTION_FILENAME), indent = 4)
DEFAULT_PROMPT_FILENAME: str = 'prompt.md'
DEFAULT_QUESTION_FILENAME: str = 'question.json'
EMPTY_ANSWER_QUESTION_TYPES: Set[quizcomp.model.constants.QuestionType] = {<QuestionType.ESSAY: 'essay'>, <QuestionType.SA: 'short_answer'>, <QuestionType.TEXT_ONLY: 'text_only'>}

Question types that do not have to have an answer.

PLACEHOLDER_QUESTION_TYPES: Set[quizcomp.model.constants.QuestionType] = {<QuestionType.FIMB: 'fill_in_multiple_blanks'>, <QuestionType.MDD: 'multiple_dropdowns'>}

Question types that have placeholders.

class Question(quizcomp.model.base.CoreType):
 33class Question(quizcomp.model.base.CoreType):
 34    """ A class that represents a question and all answers/feedback for the question. """
 35
 36    def __init__(self,
 37            question_type: quizcomp.model.constants.QuestionType,
 38            prompt: typing.Union[quizcomp.parser.document.ParsedDocument, str],
 39            answers: typing.Union[quizcomp.model.answer.QuestionAnswers, None] = None,
 40            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
 41            **kwargs: typing.Any) -> None:
 42        super().__init__(**kwargs)
 43
 44        self.question_type: quizcomp.model.constants.QuestionType = question_type
 45        """ The type of this question. """
 46
 47        if (isinstance(prompt, str)):
 48            prompt = quizcomp.parser.document.ParsedDocument.parse_text(prompt)
 49
 50        self.prompt: quizcomp.parser.document.ParsedDocument = prompt
 51        """ The parsed prompt of this question. """
 52
 53        if (answers is None):
 54            answers = quizcomp.model.answer.TextAnswers()
 55
 56        self.answers: quizcomp.model.answer.QuestionAnswers = answers
 57        """ The answers for this question. """
 58
 59        if ((feedback is not None) and feedback.is_empty()):
 60            feedback = None
 61
 62        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
 63        """ Object-level feedback. """
 64
 65    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
 66        documents = [self.prompt]
 67        documents += self.answers.collect_documents()
 68
 69        if (self.feedback is not None):
 70            documents += self.feedback.collect_documents()
 71
 72        return documents
 73
 74    @classmethod
 75    def from_pod(cls,
 76            data: edq.util.serial.PODType,
 77            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 78            ) -> 'Question':
 79        if (context is None):
 80            context = edq.util.serial.SerializationContext()
 81
 82        if (isinstance(data, dict)):
 83            return super().from_pod(data, context)
 84
 85        if (not isinstance(data, str)):
 86            raise quizcomp.model.errors.QuizValidationError(f"Cannot createquestion object from '{type(data)}' type, need dict or str (path).")
 87
 88        # If a question is being loaded from a string, it is probably a path.
 89        path = str(data)
 90        if (not os.path.isabs(path)):
 91            path = os.path.join(context.base_dir, path)
 92
 93        path = os.path.abspath(path)
 94
 95        return cls.from_path(path, context)
 96
 97    @classmethod
 98    def prep_init_data(cls,
 99            data: typing.Dict[str, typing.Any],
100            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
101            ) -> typing.Dict[str, typing.Any]:
102        if (context is None):
103            context = edq.util.serial.SerializationContext()
104
105        raw_question_type = data.get('question_type', None)
106        if (raw_question_type is None):
107            raise quizcomp.model.errors.QuestionValidationError("Question data does not include 'question_type'.", context = context)
108
109        question_type = quizcomp.model.constants.QuestionType(raw_question_type)
110        context.extra['question_type'] = question_type
111
112        raw_feedback = data.pop('feedback', None)
113
114        data = super().prep_init_data(data, context)
115
116        data['prompt'] = cls._collect_prompt(data.get('prompt', None), data.get('prompt_path', None), context)
117        data['feedback'] = quizcomp.model.feedback.Feedback.from_raw_data(raw_feedback, context)
118
119        cls._validate_init_data(data, context)
120
121        return data
122
123    @classmethod
124    def _validate_init_data(cls,
125            data: typing.Dict[str, typing.Any],
126            context: edq.util.serial.SerializationContext,
127            ) -> None:
128        """ Validate the data to be sent to __init__() right before construction. """
129
130        question_type = data.get('question_type', None)
131        if (not edq.util.enum.has_value(quizcomp.model.constants.QuestionType, question_type)):
132            raise quizcomp.model.errors.QuestionValidationError(f"Unknown question type: '{question_type}'.", context = context)
133
134        answers = data.get('answers', None)
135        if (answers is None):
136            if (question_type not in EMPTY_ANSWER_QUESTION_TYPES):
137                raise quizcomp.model.errors.QuestionValidationError('No answers to question provided.', context = context)
138
139            data['answers'] = quizcomp.model.answer.TextAnswers()
140
141        prompt_placeholders = data['prompt'].collect_placeholders()
142        if (question_type in PLACEHOLDER_QUESTION_TYPES):
143            answers_placeholders = set(data['answers'].parts.keys())
144
145            if (answers_placeholders != prompt_placeholders):
146                output_answers_placeholders = list(sorted(answers_placeholders))
147                output_prompt_placeholders = list(sorted(prompt_placeholders))
148
149                raise quizcomp.model.errors.QuestionValidationError(
150                        (f"Mismatch between the placeholders found in the question prompt ({output_prompt_placeholders})"
151                            + f" and answers config ({output_answers_placeholders})."),
152                        context = context)
153        elif (len(prompt_placeholders) != 0):
154            raise quizcomp.model.errors.QuestionValidationError(
155                    f"Found placeholders in the prompt for questions that do not use placeholders: '{question_type}'.",
156                    context = context)
157
158    @classmethod
159    def _collect_prompt(cls,
160            text: typing.Union[quizcomp.parser.document.ParsedDocument, str, None],
161            path: typing.Union[str, None],
162            context: edq.util.serial.SerializationContext,
163            ) -> quizcomp.parser.document.ParsedDocument:
164        """
165        Collect the prompt from one of several possible locations.
166
167        The prompt is allowed to appear (in order of priority):
168        1) in the `prompt` field (sent to this function as the `text` argument),
169        2) pointed to by the `prompt_path` field (send to this function as the `path` argument),
170        3) or be in `<base dir>/DEFAULT_PROMPT_FILENAME`.
171
172        Will raise an exception on an empty prompt.
173        Null, empty, and only white space all count as empty.
174        """
175
176        if (text is None):
177            text = ''
178
179        if (isinstance(text, quizcomp.parser.document.ParsedDocument)):
180            return text
181
182        text = text.strip()
183        if (len(text) != 0):
184            return quizcomp.parser.document.ParsedDocument.parse_text(text, context)
185
186        if (path is not None):
187            return quizcomp.parser.document.ParsedDocument.parse_file(path, context)
188
189        path = os.path.abspath(os.path.join(context.base_dir, DEFAULT_PROMPT_FILENAME))
190        if (not os.path.isfile(path)):
191            raise quizcomp.model.errors.QuestionValidationError("Could not find any non-empty prompt.", context = context)
192
193        return quizcomp.parser.document.ParsedDocument.parse_file(path, context)
194
195    def shuffle(self, rng: random.Random) -> None:
196        """
197        Shuffle the answers for this question.
198        This method will do nothing if question shuffling is not allowed by the config settings.
199        """
200
201        if (self.get_config(quizcomp.model.config.OPTION_SHUFFLE_ANSWERS) is not True):
202            return
203
204        self.answers.shuffle(rng)
205
206    def to_dir(self,
207            base_dir: str,
208            fetch_images: bool = True,
209            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
210            **kwargs: typing.Any) -> None:
211        self.base_dir = os.path.abspath(base_dir)
212        edq.util.dirent.mkdir(self.base_dir)
213
214        if (fetch_images):
215            self.fetch_and_update_images()
216
217        output_data = self.to_dict(context = context)
218
219        # Move the prompt to a different file.
220        output_data.pop('prompt', None)
221        if (not self.prompt.is_empty()):
222            edq.util.dirent.write_file(os.path.join(self.base_dir, DEFAULT_PROMPT_FILENAME), self.prompt.to_md())
223
224        edq.util.json.dump_path(output_data, os.path.join(self.base_dir, DEFAULT_QUESTION_FILENAME), indent = 4)

A class that represents a question and all answers/feedback for the question.

Question( question_type: quizcomp.model.constants.QuestionType, prompt: Union[quizcomp.parser.document.ParsedDocument, str], answers: Optional[quizcomp.model.answer.QuestionAnswers] = None, feedback: Optional[quizcomp.model.feedback.Feedback] = None, **kwargs: Any)
36    def __init__(self,
37            question_type: quizcomp.model.constants.QuestionType,
38            prompt: typing.Union[quizcomp.parser.document.ParsedDocument, str],
39            answers: typing.Union[quizcomp.model.answer.QuestionAnswers, None] = None,
40            feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = None,
41            **kwargs: typing.Any) -> None:
42        super().__init__(**kwargs)
43
44        self.question_type: quizcomp.model.constants.QuestionType = question_type
45        """ The type of this question. """
46
47        if (isinstance(prompt, str)):
48            prompt = quizcomp.parser.document.ParsedDocument.parse_text(prompt)
49
50        self.prompt: quizcomp.parser.document.ParsedDocument = prompt
51        """ The parsed prompt of this question. """
52
53        if (answers is None):
54            answers = quizcomp.model.answer.TextAnswers()
55
56        self.answers: quizcomp.model.answer.QuestionAnswers = answers
57        """ The answers for this question. """
58
59        if ((feedback is not None) and feedback.is_empty()):
60            feedback = None
61
62        self.feedback: typing.Union[quizcomp.model.feedback.Feedback, None] = feedback
63        """ Object-level feedback. """

The type of this question.

The parsed prompt of this question.

The answers for this question.

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

Object-level feedback.

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
65    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
66        documents = [self.prompt]
67        documents += self.answers.collect_documents()
68
69        if (self.feedback is not None):
70            documents += self.feedback.collect_documents()
71
72        return documents

Collect documents for this object only (does not include any children). Use collect_all_documents() if you want child documents as well.

@classmethod
def from_pod( cls, data: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType], context: Optional[edq.util.common.SerializationContext] = None) -> Question:
74    @classmethod
75    def from_pod(cls,
76            data: edq.util.serial.PODType,
77            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
78            ) -> 'Question':
79        if (context is None):
80            context = edq.util.serial.SerializationContext()
81
82        if (isinstance(data, dict)):
83            return super().from_pod(data, context)
84
85        if (not isinstance(data, str)):
86            raise quizcomp.model.errors.QuizValidationError(f"Cannot createquestion object from '{type(data)}' type, need dict or str (path).")
87
88        # If a question is being loaded from a string, it is probably a path.
89        path = str(data)
90        if (not os.path.isabs(path)):
91            path = os.path.join(context.base_dir, path)
92
93        path = os.path.abspath(path)
94
95        return cls.from_path(path, 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 prep_init_data( cls, data: Dict[str, Any], context: Optional[edq.util.common.SerializationContext] = None) -> Dict[str, Any]:
 97    @classmethod
 98    def prep_init_data(cls,
 99            data: typing.Dict[str, typing.Any],
100            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
101            ) -> typing.Dict[str, typing.Any]:
102        if (context is None):
103            context = edq.util.serial.SerializationContext()
104
105        raw_question_type = data.get('question_type', None)
106        if (raw_question_type is None):
107            raise quizcomp.model.errors.QuestionValidationError("Question data does not include 'question_type'.", context = context)
108
109        question_type = quizcomp.model.constants.QuestionType(raw_question_type)
110        context.extra['question_type'] = question_type
111
112        raw_feedback = data.pop('feedback', None)
113
114        data = super().prep_init_data(data, context)
115
116        data['prompt'] = cls._collect_prompt(data.get('prompt', None), data.get('prompt_path', None), context)
117        data['feedback'] = quizcomp.model.feedback.Feedback.from_raw_data(raw_feedback, context)
118
119        cls._validate_init_data(data, context)
120
121        return data

Prepare data to be passed into this class' constructor.

By default, this is called by from_pod(). A child can override this or prep_init_data() depending on the functionality they want.

A general (but inefficient) implementation is provided by default. This implementation will attempt to use type hints (of the classes constructor) to convert enums and DictDeserializers.

def shuffle(self, rng: random.Random) -> None:
195    def shuffle(self, rng: random.Random) -> None:
196        """
197        Shuffle the answers for this question.
198        This method will do nothing if question shuffling is not allowed by the config settings.
199        """
200
201        if (self.get_config(quizcomp.model.config.OPTION_SHUFFLE_ANSWERS) is not True):
202            return
203
204        self.answers.shuffle(rng)

Shuffle the answers for this question. This method will do nothing if question shuffling is not allowed by the config settings.

def to_dir( self, base_dir: str, fetch_images: bool = True, context: Optional[edq.util.common.SerializationContext] = None, **kwargs: Any) -> None:
206    def to_dir(self,
207            base_dir: str,
208            fetch_images: bool = True,
209            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
210            **kwargs: typing.Any) -> None:
211        self.base_dir = os.path.abspath(base_dir)
212        edq.util.dirent.mkdir(self.base_dir)
213
214        if (fetch_images):
215            self.fetch_and_update_images()
216
217        output_data = self.to_dict(context = context)
218
219        # Move the prompt to a different file.
220        output_data.pop('prompt', None)
221        if (not self.prompt.is_empty()):
222            edq.util.dirent.write_file(os.path.join(self.base_dir, DEFAULT_PROMPT_FILENAME), self.prompt.to_md())
223
224        edq.util.json.dump_path(output_data, os.path.join(self.base_dir, DEFAULT_QUESTION_FILENAME), indent = 4)

Write this object to the given directory. This is different than to_path(), as that function just serializes the to a single JSON file, whereas this method writes a directory in the Quiz Composer style.