quizcomp.model.group
1import glob 2import logging 3import math 4import os 5import random 6import typing 7 8import edq.util.serial 9 10import quizcomp.model.base 11import quizcomp.model.config 12import quizcomp.model.constants 13import quizcomp.model.errors 14import quizcomp.model.question 15 16_logger = logging.getLogger(__name__) 17 18DEFAULT_PICK_COUNT: int = 1 19""" The default number of questions chosen from this group. """ 20 21class Group(quizcomp.model.base.CoreType): 22 """ 23 A group/bank of questions for a quiz. 24 Questions can be grouped together and then a subset can be randomly chosen to create variety in quizzes. 25 """ 26 27 def __init__(self, 28 children: typing.Union[typing.List[quizcomp.model.question.Question], None] = None, 29 pick_count: int = DEFAULT_PICK_COUNT, 30 **kwargs: typing.Any) -> None: 31 # Remove aliases before super construction. 32 kwargs.pop('questions', None) 33 34 super().__init__(children = children, **kwargs) 35 36 if (pick_count < 0): 37 raise quizcomp.model.errors.QuizValidationError(f"Pick count must be non-negative, found: {pick_count}.", context = self) 38 39 self.pick_count: int = pick_count 40 """ The number of questions to choose from this group. """ 41 42 if (self.pick_count > self.child_count()): 43 _logger.warning("Group '%s' was asked to pick more questions than available (pick count: %d, group size: %d).", 44 self.name, self.pick_count, self.child_count()) 45 self.pick_count = self.child_count() 46 47 def get_questions(self) -> typing.List[quizcomp.model.question.Question]: 48 """ Get all questions for this group. """ 49 50 return [typing.cast(quizcomp.model.question.Question, child) for child in self.children] 51 52 def get_child_points(self) -> typing.Union[float, int]: 53 if (self.pick_count == 0): 54 return 0 55 56 # Make sure to not try to use the children to compute the available points. 57 value = self.get_points(check_children = False) / float(self.pick_count) 58 if (math.isclose(value, int(value))): 59 value = int(value) 60 61 return value 62 63 def choose_variant_questions(self, 64 all_questions: bool, 65 used_question_indexes: typing.Set[int], 66 rng: random.Random, 67 ) -> typing.List[quizcomp.model.question.Question]: 68 """ 69 Get a list of questions to use for an instantiated variant of this group. 70 The returned questions will be copies of the original and shuffled (if set in config). 71 """ 72 73 if ((self.pick_count == 0) or (self.child_count() == 0)): 74 _logger.warning("Group '%s' will select no questions (pick_count: %d, group size: %d).", 75 self.name, self.pick_count, self.child_count()) 76 return [] 77 78 count = self.pick_count 79 if (all_questions): 80 count = self.child_count() 81 82 questions = self._choose_questions(count, used_question_indexes, rng) 83 84 return questions 85 86 def _choose_questions(self, 87 count: int, 88 used_question_indexes: typing.Set[int], 89 rng: random.Random, 90 ) -> typing.List[quizcomp.model.question.Question]: 91 """ Internally, choose a list of questions to use for an instantiated variant of this group. """ 92 93 indexes = list(range(self.child_count())) 94 95 with_replacement = (self.get_config(quizcomp.model.config.OPTION_PICK_WITH_REPLACEMENT) is True) 96 97 if (not with_replacement): 98 indexes = list(set(indexes) - used_question_indexes) 99 100 if (count > len(indexes)): 101 _logger.warning( 102 "Group '%s' does not have enough questions to pick without replacement, now choosing questions with replacement.", 103 self.name, 104 ) 105 # Reset the selection pool. 106 indexes = list(range(self.child_count())) 107 used_question_indexes.clear() 108 109 rng.shuffle(indexes) 110 indexes = indexes[:count] 111 112 if (not with_replacement): 113 used_question_indexes |= set(indexes) 114 115 questions = [] 116 for index in indexes: 117 question = typing.cast(quizcomp.model.question.Question, self.children[index].copy()) 118 question.shuffle(rng) 119 120 questions.append(question) 121 122 return questions 123 124 def to_dict(self, 125 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 126 ) -> typing.Dict[str, edq.util.serial.PODType]: 127 data = super().to_dict(context) 128 data['questions'] = data.pop('children', data.get('questions', None)) 129 return data 130 131 @classmethod 132 def from_dict(cls, 133 data: typing.Dict[str, edq.util.serial.PODType], 134 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 135 ) -> 'Group': 136 if (context is None): 137 context = edq.util.serial.SerializationContext() 138 139 # Expand any question paths that be directories. 140 cls._expand_questions(data, context) 141 return super().from_dict(data, context) 142 143 @classmethod 144 def _expand_questions(cls, 145 data: typing.Dict[str, edq.util.serial.PODType], 146 context: edq.util.serial.SerializationContext, 147 ) -> None: 148 """ 149 Expand the 'questions' field into the 'children' field, 150 in a form that Question.from_pod() can read 151 (which includes a path to a question). 152 153 This allows questions to be provided as a path. 154 If that path is a dir, then recursivley load all questions found in that dir. 155 """ 156 157 new_questions: typing.List[edq.util.serial.PODType] = [] 158 raw_questions = data.pop('questions', data.get('children', [])) 159 160 quizcomp.model.errors.check_type(raw_questions, list, "'questions'", context = context) 161 old_questions = typing.cast(typing.List[edq.util.serial.PODType], raw_questions) 162 163 for raw_question in old_questions: 164 if (not isinstance(raw_question, str)): 165 new_questions.append(raw_question) 166 continue 167 168 path = str(raw_question) 169 if (not os.path.isabs(path)): 170 path = os.path.join(context.base_dir, path) 171 172 path = os.path.abspath(path) 173 174 if (os.path.isdir(path)): 175 for subpath in sorted(glob.glob(os.path.join(path, '**', quizcomp.model.constants.QUESTION_FILENAME), recursive = True)): 176 new_questions.append(subpath) 177 178 else: 179 new_questions.append(path) 180 181 data['children'] = new_questions
The default number of questions chosen from this group.
22class Group(quizcomp.model.base.CoreType): 23 """ 24 A group/bank of questions for a quiz. 25 Questions can be grouped together and then a subset can be randomly chosen to create variety in quizzes. 26 """ 27 28 def __init__(self, 29 children: typing.Union[typing.List[quizcomp.model.question.Question], None] = None, 30 pick_count: int = DEFAULT_PICK_COUNT, 31 **kwargs: typing.Any) -> None: 32 # Remove aliases before super construction. 33 kwargs.pop('questions', None) 34 35 super().__init__(children = children, **kwargs) 36 37 if (pick_count < 0): 38 raise quizcomp.model.errors.QuizValidationError(f"Pick count must be non-negative, found: {pick_count}.", context = self) 39 40 self.pick_count: int = pick_count 41 """ The number of questions to choose from this group. """ 42 43 if (self.pick_count > self.child_count()): 44 _logger.warning("Group '%s' was asked to pick more questions than available (pick count: %d, group size: %d).", 45 self.name, self.pick_count, self.child_count()) 46 self.pick_count = self.child_count() 47 48 def get_questions(self) -> typing.List[quizcomp.model.question.Question]: 49 """ Get all questions for this group. """ 50 51 return [typing.cast(quizcomp.model.question.Question, child) for child in self.children] 52 53 def get_child_points(self) -> typing.Union[float, int]: 54 if (self.pick_count == 0): 55 return 0 56 57 # Make sure to not try to use the children to compute the available points. 58 value = self.get_points(check_children = False) / float(self.pick_count) 59 if (math.isclose(value, int(value))): 60 value = int(value) 61 62 return value 63 64 def choose_variant_questions(self, 65 all_questions: bool, 66 used_question_indexes: typing.Set[int], 67 rng: random.Random, 68 ) -> typing.List[quizcomp.model.question.Question]: 69 """ 70 Get a list of questions to use for an instantiated variant of this group. 71 The returned questions will be copies of the original and shuffled (if set in config). 72 """ 73 74 if ((self.pick_count == 0) or (self.child_count() == 0)): 75 _logger.warning("Group '%s' will select no questions (pick_count: %d, group size: %d).", 76 self.name, self.pick_count, self.child_count()) 77 return [] 78 79 count = self.pick_count 80 if (all_questions): 81 count = self.child_count() 82 83 questions = self._choose_questions(count, used_question_indexes, rng) 84 85 return questions 86 87 def _choose_questions(self, 88 count: int, 89 used_question_indexes: typing.Set[int], 90 rng: random.Random, 91 ) -> typing.List[quizcomp.model.question.Question]: 92 """ Internally, choose a list of questions to use for an instantiated variant of this group. """ 93 94 indexes = list(range(self.child_count())) 95 96 with_replacement = (self.get_config(quizcomp.model.config.OPTION_PICK_WITH_REPLACEMENT) is True) 97 98 if (not with_replacement): 99 indexes = list(set(indexes) - used_question_indexes) 100 101 if (count > len(indexes)): 102 _logger.warning( 103 "Group '%s' does not have enough questions to pick without replacement, now choosing questions with replacement.", 104 self.name, 105 ) 106 # Reset the selection pool. 107 indexes = list(range(self.child_count())) 108 used_question_indexes.clear() 109 110 rng.shuffle(indexes) 111 indexes = indexes[:count] 112 113 if (not with_replacement): 114 used_question_indexes |= set(indexes) 115 116 questions = [] 117 for index in indexes: 118 question = typing.cast(quizcomp.model.question.Question, self.children[index].copy()) 119 question.shuffle(rng) 120 121 questions.append(question) 122 123 return questions 124 125 def to_dict(self, 126 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 127 ) -> typing.Dict[str, edq.util.serial.PODType]: 128 data = super().to_dict(context) 129 data['questions'] = data.pop('children', data.get('questions', None)) 130 return data 131 132 @classmethod 133 def from_dict(cls, 134 data: typing.Dict[str, edq.util.serial.PODType], 135 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 136 ) -> 'Group': 137 if (context is None): 138 context = edq.util.serial.SerializationContext() 139 140 # Expand any question paths that be directories. 141 cls._expand_questions(data, context) 142 return super().from_dict(data, context) 143 144 @classmethod 145 def _expand_questions(cls, 146 data: typing.Dict[str, edq.util.serial.PODType], 147 context: edq.util.serial.SerializationContext, 148 ) -> None: 149 """ 150 Expand the 'questions' field into the 'children' field, 151 in a form that Question.from_pod() can read 152 (which includes a path to a question). 153 154 This allows questions to be provided as a path. 155 If that path is a dir, then recursivley load all questions found in that dir. 156 """ 157 158 new_questions: typing.List[edq.util.serial.PODType] = [] 159 raw_questions = data.pop('questions', data.get('children', [])) 160 161 quizcomp.model.errors.check_type(raw_questions, list, "'questions'", context = context) 162 old_questions = typing.cast(typing.List[edq.util.serial.PODType], raw_questions) 163 164 for raw_question in old_questions: 165 if (not isinstance(raw_question, str)): 166 new_questions.append(raw_question) 167 continue 168 169 path = str(raw_question) 170 if (not os.path.isabs(path)): 171 path = os.path.join(context.base_dir, path) 172 173 path = os.path.abspath(path) 174 175 if (os.path.isdir(path)): 176 for subpath in sorted(glob.glob(os.path.join(path, '**', quizcomp.model.constants.QUESTION_FILENAME), recursive = True)): 177 new_questions.append(subpath) 178 179 else: 180 new_questions.append(path) 181 182 data['children'] = new_questions
A group/bank of questions for a quiz. Questions can be grouped together and then a subset can be randomly chosen to create variety in quizzes.
28 def __init__(self, 29 children: typing.Union[typing.List[quizcomp.model.question.Question], None] = None, 30 pick_count: int = DEFAULT_PICK_COUNT, 31 **kwargs: typing.Any) -> None: 32 # Remove aliases before super construction. 33 kwargs.pop('questions', None) 34 35 super().__init__(children = children, **kwargs) 36 37 if (pick_count < 0): 38 raise quizcomp.model.errors.QuizValidationError(f"Pick count must be non-negative, found: {pick_count}.", context = self) 39 40 self.pick_count: int = pick_count 41 """ The number of questions to choose from this group. """ 42 43 if (self.pick_count > self.child_count()): 44 _logger.warning("Group '%s' was asked to pick more questions than available (pick count: %d, group size: %d).", 45 self.name, self.pick_count, self.child_count()) 46 self.pick_count = self.child_count()
48 def get_questions(self) -> typing.List[quizcomp.model.question.Question]: 49 """ Get all questions for this group. """ 50 51 return [typing.cast(quizcomp.model.question.Question, child) for child in self.children]
Get all questions for this group.
53 def get_child_points(self) -> typing.Union[float, int]: 54 if (self.pick_count == 0): 55 return 0 56 57 # Make sure to not try to use the children to compute the available points. 58 value = self.get_points(check_children = False) / float(self.pick_count) 59 if (math.isclose(value, int(value))): 60 value = int(value) 61 62 return value
Get the points available for a child of this object. By default, this is the number of available points divided evenly amongst the children. If no point configuration can be found, DEFAULT_AVAILABLE_POINTS should be returned.
64 def choose_variant_questions(self, 65 all_questions: bool, 66 used_question_indexes: typing.Set[int], 67 rng: random.Random, 68 ) -> typing.List[quizcomp.model.question.Question]: 69 """ 70 Get a list of questions to use for an instantiated variant of this group. 71 The returned questions will be copies of the original and shuffled (if set in config). 72 """ 73 74 if ((self.pick_count == 0) or (self.child_count() == 0)): 75 _logger.warning("Group '%s' will select no questions (pick_count: %d, group size: %d).", 76 self.name, self.pick_count, self.child_count()) 77 return [] 78 79 count = self.pick_count 80 if (all_questions): 81 count = self.child_count() 82 83 questions = self._choose_questions(count, used_question_indexes, rng) 84 85 return questions
Get a list of questions to use for an instantiated variant of this group. The returned questions will be copies of the original and shuffled (if set in config).
125 def to_dict(self, 126 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 127 ) -> typing.Dict[str, edq.util.serial.PODType]: 128 data = super().to_dict(context) 129 data['questions'] = data.pop('children', data.get('questions', None)) 130 return data
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
132 @classmethod 133 def from_dict(cls, 134 data: typing.Dict[str, edq.util.serial.PODType], 135 context: typing.Union[edq.util.serial.SerializationContext, None] = None, 136 ) -> 'Group': 137 if (context is None): 138 context = edq.util.serial.SerializationContext() 139 140 # Expand any question paths that be directories. 141 cls._expand_questions(data, context) 142 return super().from_dict(data, context)
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be equivalent to the original.
By default, this function just calls the class' constructor with the output of prep_init_data(). 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.
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_documents
- collect_all_documents
- get_points
- get_display_points
- get_attribute
- get_hint
- get_style
- get_config
- get_known_config
- to_path
- to_dir
- fetch_and_update_images
- copy