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)
Question types that do not have to have an answer.
Question types that have placeholders.
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.
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. """
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.
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.
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.
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.
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.
Inherited Members
- quizcomp.model.base.CoreType
- serialization_omit_none
- serialization_omit_empty
- serialization_error_class
- serialization_skip_fields
- base_dir
- source_path
- name
- parent
- children
- points
- lms_id
- attributes
- attributes_first
- attributes_last
- hints
- hints_first
- hints_last
- style
- style_first
- style_last
- child_count
- get_name
- get_parent_index
- collect_all_documents
- get_points
- get_child_points
- get_display_points
- get_attribute
- get_hint
- get_style
- get_config
- get_known_config
- to_path
- fetch_and_update_images
- copy