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
DEFAULT_PICK_COUNT: int = 1

The default number of questions chosen from this group.

class Group(quizcomp.model.base.CoreType):
 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.

Group( children: Optional[List[quizcomp.model.question.Question]] = None, pick_count: int = 1, **kwargs: Any)
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()
pick_count: int

The number of questions to choose from this group.

def get_questions(self) -> List[quizcomp.model.question.Question]:
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.

def get_child_points(self) -> Union[float, int]:
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.

def choose_variant_questions( self, all_questions: bool, used_question_indexes: Set[int], rng: random.Random) -> List[quizcomp.model.question.Question]:
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).

def to_dict( self, context: Optional[edq.util.common.SerializationContext] = None) -> Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]:
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.

@classmethod
def from_dict( cls, data: Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], context: Optional[edq.util.common.SerializationContext] = None) -> Group:
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.